keystone-design-bootstrap 1.0.49 → 1.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { C as CompanyInformation, S as Service, F as FormDefinition } from '../form-BLZuTGkr.js';
2
- import { P as PhotoAttachment, W as WebsitePhotos } from '../photos-8jMeetqV.js';
1
+ import { C as CompanyInformation, F as FormDefinition, S as Service } from '../form-CpsCONG5.js';
2
+ import { W as WebsitePhotos } from '../website-photos-Bm-CBK9g.js';
3
3
 
4
4
  interface FetchOptions {
5
5
  cache?: RequestCache;
@@ -39,48 +39,5 @@ declare function getPackage(slug: string): Promise<unknown>;
39
39
  declare function getTestimonials(): Promise<unknown>;
40
40
  /** Form definition for dynamic form rendering (fields may include optional placeholder). */
41
41
  declare function getForm(formType: string): Promise<FormDefinition | null>;
42
- /** Minimal service for offer detail (from GET /public/offers/:id). */
43
- interface OfferServiceSummary {
44
- id: number;
45
- name: string;
46
- slug: string;
47
- summary?: string | null;
48
- }
49
- /** Package summary (nested on each offer in list and single-offer responses). */
50
- interface OfferPackageSummary {
51
- id: number;
52
- name: string;
53
- slug: string;
54
- summary?: string | null;
55
- description_markdown?: string | null;
56
- }
57
- /** Offer from public API. */
58
- interface OfferPublic {
59
- id: number;
60
- name: string;
61
- description: string | null;
62
- value_terms: string | null;
63
- /** Optional; when absent or null, offer has no expiration. */
64
- expires_at?: string | null;
65
- expired?: boolean;
66
- /** Service IDs (used for category_names fallback when not provided by API). */
67
- service_ids?: number[];
68
- /** Package IDs (included in list response; nested services/packages are the source of truth). */
69
- package_ids?: number[];
70
- /** Category names from API (services + service_items + packages). */
71
- category_names?: string[];
72
- /** Photo attachments from related services (same shape as Service.photo_attachments). */
73
- photo_attachments?: PhotoAttachment[];
74
- /** Present when fetching single offer (GET /public/offers/:id). */
75
- services?: OfferServiceSummary[];
76
- /** Specific menu items this offer applies to (single offer only). */
77
- service_items?: OfferServiceSummary[];
78
- /** Packages this offer applies to (single offer only). */
79
- packages?: OfferPackageSummary[];
80
- }
81
- /** List offers for the current account (API key scoped). */
82
- declare function getOffers(): Promise<OfferPublic[] | null>;
83
- /** Single offer with services for detail page. */
84
- declare function getOffer(id: number | string): Promise<OfferPublic | null>;
85
42
 
86
- export { type OfferPackageSummary, type OfferPublic, type OfferServiceSummary, getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getOffer, getOffers, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
43
+ export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
@@ -96,13 +96,6 @@ async function getTestimonials() {
96
96
  async function getForm(formType) {
97
97
  return serverFetch(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
98
98
  }
99
- async function getOffers() {
100
- const data = await serverFetch("/public/offers", defaultOptions);
101
- return Array.isArray(data) ? data : null;
102
- }
103
- async function getOffer(id) {
104
- return serverFetch(`/public/offers/${id}`, defaultOptions);
105
- }
106
99
  export {
107
100
  getAdsConfig,
108
101
  getBlogPost,
@@ -115,8 +108,6 @@ export {
115
108
  getLocation,
116
109
  getLocations,
117
110
  getMetaPixelId,
118
- getOffer,
119
- getOffers,
120
111
  getPackage,
121
112
  getPackages,
122
113
  getReviews,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { Service } from '../types/api/service';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices() {\n return serverFetch('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages() {\n return serverFetch('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string) {\n return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n/** Minimal service for offer detail (from GET /public/offers/:id). */\nexport interface OfferServiceSummary {\n id: number;\n name: string;\n slug: string;\n summary?: string | null;\n}\n\n/** Package summary (nested on each offer in list and single-offer responses). */\nexport interface OfferPackageSummary {\n id: number;\n name: string;\n slug: string;\n summary?: string | null;\n description_markdown?: string | null;\n}\n\n/** Offer from public API. */\nexport interface OfferPublic {\n id: number;\n name: string;\n description: string | null;\n value_terms: string | null;\n /** Optional; when absent or null, offer has no expiration. */\n expires_at?: string | null;\n expired?: boolean;\n /** Service IDs (used for category_names fallback when not provided by API). */\n service_ids?: number[];\n /** Package IDs (included in list response; nested services/packages are the source of truth). */\n package_ids?: number[];\n /** Category names from API (services + service_items + packages). */\n category_names?: string[];\n /** Photo attachments from related services (same shape as Service.photo_attachments). */\n photo_attachments?: PhotoAttachment[];\n /** Present when fetching single offer (GET /public/offers/:id). */\n services?: OfferServiceSummary[];\n /** Specific menu items this offer applies to (single offer only). */\n service_items?: OfferServiceSummary[];\n /** Packages this offer applies to (single offer only). */\n packages?: OfferPackageSummary[];\n}\n\n/** List offers for the current account (API key scoped). */\nexport async function getOffers(): Promise<OfferPublic[] | null> {\n const data = await serverFetch<OfferPublic[]>('/public/offers', defaultOptions);\n return Array.isArray(data) ? data : null;\n}\n\n/** Single offer with services for detail page. */\nexport async function getOffer(id: number | string): Promise<OfferPublic | null> {\n return serverFetch<OfferPublic>(`/public/offers/${id}`, defaultOptions);\n}\n\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices() {\n return serverFetch('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages() {\n return serverFetch('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string) {\n return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n"],"mappings":"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","names":[]}
@@ -1,8 +1,8 @@
1
1
  import { Theme } from '../themes/index.js';
2
- export { B as BlogPost, c as BlogPostAuthor, a as BlogPostParams, b as BlogPostResponse, d as BlogPostTag } from '../blog-post-D7HFCDp1.js';
3
- export { C as CompanyInformation, a as CompanyInformationResponse, F as FormDefinition, b as FormFieldDefinition, c as FormFieldItem, d as FormType, S as Service, e as ServiceItem, f as ServiceParams, g as ServiceResponse } from '../form-BLZuTGkr.js';
4
- import { P as PhotoAttachment } from '../photos-8jMeetqV.js';
5
- export { a as Photo, b as WebsitePhoto, W as WebsitePhotos, c as WebsitePhotosResponse } from '../photos-8jMeetqV.js';
2
+ export { B as BlogPost, a as BlogPostAuthor, b as BlogPostParams, c as BlogPostResponse, d as BlogPostTag } from '../blog-post-DGjaJ3wf.js';
3
+ export { C as CompanyInformation, a as CompanyInformationResponse, F as FormDefinition, b as FormFieldDefinition, c as FormFieldItem, d as FormType, O as OfferPublic, S as Service, e as ServiceItem, f as ServiceParams, g as ServiceResponse } from '../form-CpsCONG5.js';
4
+ import { P as PhotoAttachment } from '../website-photos-Bm-CBK9g.js';
5
+ export { a as Photo, b as WebsitePhoto, W as WebsitePhotos, c as WebsitePhotosResponse } from '../website-photos-Bm-CBK9g.js';
6
6
 
7
7
  interface NavItem {
8
8
  label: string;
@@ -1,4 +1,4 @@
1
- import { P as PhotoAttachment, W as WebsitePhotos } from '../photos-8jMeetqV.js';
1
+ import { P as PhotoAttachment, W as WebsitePhotos } from '../website-photos-Bm-CBK9g.js';
2
2
 
3
3
  /**
4
4
  * Helper functions for extracting photo URLs from photo associations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.49",
3
+ "version": "1.0.51",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -46,9 +46,6 @@ import { HomeHeroComponent as BaseHomeHeroComponent } from './home-hero-componen
46
46
  import { JobDetailHero as BaseJobDetailHero } from './hero-job-detail';
47
47
  import { JobDetailSection as BaseJobDetailSection } from './job-detail-section';
48
48
  import { PolicyDocumentSection as BasePolicyDocumentSection } from './policy-document-section';
49
- import { OffersSection as BaseOffersSection } from './offers-section';
50
- import { OffersGallery as BaseOffersGallery } from './offers-gallery';
51
- import { OfferDetailSection as BaseOfferDetailSection } from './offer-detail';
52
49
  // Import variant files to trigger their registration
53
50
  // Aman theme variants
54
51
  import './hero-home.aman';
@@ -94,7 +91,7 @@ import './social-media-grid.barelux';
94
91
  import './contact-section.barelux';
95
92
  import './footer-home.barelux';
96
93
 
97
- // Balance theme variants (offers-section uses base for balance)
94
+ // Balance theme variants
98
95
  import './hero-home.balance';
99
96
  import './header-navigation.balance';
100
97
  import './footer-home.balance';
@@ -181,19 +178,7 @@ export const HomeHeroComponent = createThemedExport('home-hero-component', BaseH
181
178
  // Re-export application form (client component, no theme variants)
182
179
  export { JobApplicationForm } from './job-application-form';
183
180
 
184
- // Offers section (themed; embeddable on multiple pages)
185
- export const OffersSection = createThemedExport('offers-section', BaseOffersSection as unknown as React.ComponentType<Record<string, unknown>>);
186
-
187
- // Full offers page gallery (themed; use on /offers page, Aman = blog-style)
188
- export const OffersGallery = createThemedExport('offers-gallery', BaseOffersGallery as unknown as React.ComponentType<Record<string, unknown>>);
189
-
190
- // Single offer detail (themed; use on /offers/[id] page)
191
- export const OfferDetailSection = createThemedExport('offer-detail', BaseOfferDetailSection as unknown as React.ComponentType<Record<string, unknown>>);
192
-
193
- // Full offers page grid (no theme variant; fallback when gallery not used)
194
- export { OffersGrid } from './offers-grid';
195
-
196
- // Service Menu: up to 3 carousel rows (Offers, Packages, Services) with smaller cards
181
+ // Service Menu: packages + treatments; nested specials as badges / modal callouts
197
182
  export { ServiceMenuSection } from './service-menu-section';
198
183
  export type { ServiceMenuSectionProps, PackagePublic } from './service-menu-section';
199
184
 
@@ -3,7 +3,7 @@
3
3
  import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { PhotoWithFallback, Carousel, Modal, MarkdownRenderer, Button } from '../elements';
6
- import type { OfferPublic } from '../../lib/server-api';
6
+ import type { OfferPublic } from '../../types/api/offer';
7
7
  import type { Service, ServiceItem } from '../../types/api/service';
8
8
  import type { WebsitePhotos } from '../../types/api/website-photos';
9
9
  import type { CompanyInformation } from '../../types/api/company-information';
@@ -34,6 +34,7 @@ export interface PackagePublic {
34
34
  price_cents?: number | null;
35
35
  photo_attachments?: PhotoAttachment[];
36
36
  package_items?: Array<{ quantity: number; service_item?: { id: number; name: string; slug: string; summary?: string | null } }>;
37
+ offers?: OfferPublic[];
37
38
  }
38
39
 
39
40
  /** Package with category names and first-service description fallback (derived from services). */
@@ -42,16 +43,9 @@ export interface PackageForMenu extends PackagePublic {
42
43
  first_service_description_markdown?: string | null;
43
44
  }
44
45
 
45
- /** Offer with category names and first-service description fallback (derived from services). */
46
- export interface OfferForMenu extends OfferPublic {
47
- category_names?: string[];
48
- first_service_description_markdown?: string | null;
49
- }
50
-
51
46
  export interface ServiceMenuSectionProps {
52
47
  title?: string;
53
48
  subtitle?: string;
54
- offers?: OfferPublic[] | null;
55
49
  packages?: PackagePublic[] | null;
56
50
  /** Services (used to derive service items for the third row). */
57
51
  services?: Service[] | null;
@@ -74,10 +68,14 @@ export interface ServiceItemWithService extends ServiceItem {
74
68
  }
75
69
 
76
70
  type DetailItem =
77
- | { type: 'offer'; item: OfferForMenu }
78
71
  | { type: 'package'; item: PackageForMenu }
79
72
  | { type: 'service_item'; item: ServiceItemWithService };
80
73
 
74
+ function getActivePublicOffers(offers: OfferPublic[] | undefined | null): OfferPublic[] {
75
+ if (!Array.isArray(offers) || offers.length === 0) return [];
76
+ return offers.filter((o) => o.active !== false && o.expired !== true);
77
+ }
78
+
81
79
  function photoAttachmentDisplayUrl(pa: PhotoAttachment): string | undefined {
82
80
  return pa.photo?.large_url || pa.photo?.medium_url;
83
81
  }
@@ -138,6 +136,7 @@ function GridCardWithImage({
138
136
  websitePhotos,
139
137
  companyInformation,
140
138
  cycleSeed,
139
+ hasSpecial,
141
140
  }: {
142
141
  photoAttachments?: PhotoAttachment[];
143
142
  fallbackId: string | number;
@@ -149,6 +148,8 @@ function GridCardWithImage({
149
148
  websitePhotos?: WebsitePhotos | null;
150
149
  companyInformation?: CompanyInformation | null;
151
150
  cycleSeed?: string;
151
+ /** Show a small “Special” badge when this package or treatment has active offers. */
152
+ hasSpecial?: boolean;
152
153
  }) {
153
154
  const seed = cycleSeed ?? String(fallbackId);
154
155
  const list = useCycledPhotoList(photoAttachments, seed);
@@ -192,6 +193,14 @@ function GridCardWithImage({
192
193
  className="flex flex-col h-full w-full text-left group block rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand-accent focus-visible:ring-offset-2"
193
194
  >
194
195
  <div className="w-full h-36 overflow-hidden rounded-lg mb-3 relative">
196
+ {hasSpecial ? (
197
+ <span
198
+ className="absolute top-2 right-2 z-10 rounded-full border border-brand-accent/40 bg-secondary/95 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent shadow-sm backdrop-blur-sm"
199
+ aria-label="Has special offer"
200
+ >
201
+ Special
202
+ </span>
203
+ ) : null}
195
204
  {list.length === 0 ? (
196
205
  <PhotoWithFallback
197
206
  item={undefined}
@@ -350,7 +359,37 @@ function ModalSection({ title, children }: { title: string; children: React.Reac
350
359
  );
351
360
  }
352
361
 
353
- /** Renders modal body for the selected offer, package, or service item. */
362
+ function ActiveOffersCallout({ offers }: { offers: OfferPublic[] }) {
363
+ if (!offers.length) return null;
364
+ return (
365
+ <div className="rounded-xl border border-secondary bg-secondary/25 p-4 md:p-5 space-y-3 ring-1 ring-brand-accent/25">
366
+ <p className="text-xs font-semibold uppercase tracking-wide text-brand-accent">Current specials</p>
367
+ <ul className="space-y-3 list-none m-0 p-0">
368
+ {offers.map((o) => (
369
+ <li
370
+ key={o.id}
371
+ className="rounded-lg border border-secondary border-l-[3px] border-l-brand-accent bg-primary_hover/30 p-4"
372
+ >
373
+ <p className="font-display text-base font-medium text-fg-primary">{o.name}</p>
374
+ {o.value_terms ? (
375
+ <p className="font-body text-sm text-brand-accent mt-1 font-medium">{o.value_terms}</p>
376
+ ) : null}
377
+ {o.description ? (
378
+ <p className="font-body text-sm text-fg-secondary mt-2 leading-relaxed">{o.description}</p>
379
+ ) : null}
380
+ {o.expires_at ? (
381
+ <p className="text-xs text-tertiary mt-2">
382
+ Ends {new Date(o.expires_at).toLocaleDateString(undefined, { dateStyle: 'medium' })}
383
+ </p>
384
+ ) : null}
385
+ </li>
386
+ ))}
387
+ </ul>
388
+ </div>
389
+ );
390
+ }
391
+
392
+ /** Modal body for package or service item (specials shown inline when present). */
354
393
  function DetailModalContent({
355
394
  detail,
356
395
  serviceItems = [],
@@ -360,90 +399,18 @@ function DetailModalContent({
360
399
  packages?: PackageForMenu[];
361
400
  serviceItems?: ServiceItemWithService[];
362
401
  }) {
363
- if (detail.type === 'offer') {
364
- const o = detail.item;
365
- const categoryLine = o.category_names?.length ? o.category_names.join(' | ') : null;
366
- const descriptionContent = o.description || o.first_service_description_markdown;
367
- // Backend includes nested services, service_items, and packages on every offer (list and single).
368
- const relatedServices = o.services?.length ? o.services : (o.service_items ?? []);
369
- const relatedPackages = o.packages ?? [];
370
-
371
- return (
372
- <div className="space-y-6">
373
- {categoryLine && (
374
- <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{categoryLine}</p>
375
- )}
376
- {o.value_terms && !categoryLine && (
377
- <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{o.value_terms}</p>
378
- )}
379
- {descriptionContent && (
380
- <ModalSection title="Offer details">
381
- <div className="prose prose-sm font-body text-fg-primary max-w-none">
382
- {o.description ? <p className="text-fg-primary">{o.description}</p> : <MarkdownRenderer content={o.first_service_description_markdown!} />}
383
- </div>
384
- </ModalSection>
385
- )}
386
- {o.expires_at && (() => {
387
- const d = new Date(o.expires_at);
388
- if (Number.isNaN(d.getTime())) return null;
389
- return (
390
- <p className="text-sm text-tertiary">
391
- Expires {d.toLocaleDateString(undefined, { dateStyle: 'medium' })}
392
- </p>
393
- );
394
- })()}
395
-
396
- {relatedServices.length > 0 && (
397
- <ModalSection title="Related services">
398
- <ul className="space-y-4">
399
- {relatedServices.map((s: Service | { id: number; name: string; summary?: string | null }) => (
400
- <li key={s.id} className="border border-secondary rounded-lg p-4 bg-secondary/20">
401
- <p className="font-display font-medium text-fg-primary">{s.name}</p>
402
- {'description_markdown' in s && s.description_markdown ? (
403
- <div className="mt-2 prose prose-sm font-body text-fg-secondary max-w-none">
404
- <MarkdownRenderer content={s.description_markdown} />
405
- </div>
406
- ) : s.summary ? (
407
- <p className="mt-2 font-body text-sm text-fg-secondary">{s.summary}</p>
408
- ) : null}
409
- </li>
410
- ))}
411
- </ul>
412
- </ModalSection>
413
- )}
414
-
415
- {relatedPackages.length > 0 && (
416
- <ModalSection title="Related packages">
417
- <ul className="space-y-4">
418
- {relatedPackages.map((pkg) => (
419
- <li key={pkg.id} className="border border-secondary rounded-lg p-4 bg-secondary/20">
420
- <p className="font-display font-medium text-fg-primary">{pkg.name}</p>
421
- {'description_markdown' in pkg && pkg.description_markdown ? (
422
- <div className="mt-2 prose prose-sm font-body text-fg-secondary max-w-none">
423
- <MarkdownRenderer content={pkg.description_markdown} />
424
- </div>
425
- ) : pkg.summary ? (
426
- <p className="mt-2 font-body text-sm text-fg-secondary">{pkg.summary}</p>
427
- ) : null}
428
- </li>
429
- ))}
430
- </ul>
431
- </ModalSection>
432
- )}
433
- </div>
434
- );
435
- }
436
-
437
402
  if (detail.type === 'package') {
438
403
  const p = detail.item;
439
404
  const categoryLine = p.category_names?.length ? p.category_names.join(' | ') : null;
440
405
  const descriptionMarkdown = p.description_markdown || p.summary || p.first_service_description_markdown;
406
+ const packageSpecials = getActivePublicOffers(p.offers);
441
407
 
442
408
  return (
443
409
  <div className="space-y-6">
444
410
  {categoryLine && (
445
411
  <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{categoryLine}</p>
446
412
  )}
413
+ <ActiveOffersCallout offers={packageSpecials} />
447
414
  {descriptionMarkdown && (
448
415
  <ModalSection title="Package details">
449
416
  <div className="prose prose-sm font-body text-fg-primary max-w-none">
@@ -497,12 +464,14 @@ function DetailModalContent({
497
464
  const priceStr = formatPriceCents(si.price_cents);
498
465
  const summary = si.summary || si.service_summary;
499
466
  const descriptionMarkdown = si.description_markdown || si.service_description_markdown;
467
+ const itemSpecials = getActivePublicOffers(si.offers);
500
468
 
501
469
  return (
502
470
  <div className="space-y-6">
503
471
  {si.service_name && (
504
472
  <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{si.service_name}</p>
505
473
  )}
474
+ <ActiveOffersCallout offers={itemSpecials} />
506
475
 
507
476
  {priceStr && (
508
477
  <ModalSection title="Price">
@@ -541,14 +510,11 @@ function DetailModalContent({
541
510
  }
542
511
 
543
512
  /**
544
- * Service Menu section: up to 3 independent carousel rows (Offers, Packages, Service Items).
545
- * Service items are derived from services (each service’s service_items with service name).
546
- * Cards cycle through photos; clicking opens a detail modal.
513
+ * Service Menu: packages and treatments (service items). Specials appear as card badges and in modals.
547
514
  */
548
515
  export function ServiceMenuSection({
549
516
  title = 'Service Menu',
550
517
  subtitle,
551
- offers = null,
552
518
  packages = null,
553
519
  services = null,
554
520
  websitePhotos,
@@ -558,10 +524,6 @@ export function ServiceMenuSection({
558
524
  servicesRowTitle = 'Treatments',
559
525
  variant = 'section',
560
526
  }: ServiceMenuSectionProps) {
561
- const offerList = React.useMemo(
562
- () => (Array.isArray(offers) ? offers.filter((o) => !o.expired) : []),
563
- [offers]
564
- );
565
527
  const packageList = React.useMemo(
566
528
  () => (Array.isArray(packages) ? packages : []),
567
529
  [packages]
@@ -596,23 +558,6 @@ export function ServiceMenuSection({
596
558
  [packageList, serviceItemIdToService]
597
559
  );
598
560
 
599
- const offersForMenu: OfferForMenu[] = React.useMemo(
600
- () =>
601
- offerList.map((offer) => {
602
- const category_names =
603
- (offer.category_names?.length ? offer.category_names : null) ??
604
- ((offer.service_ids || [])
605
- .map((id) => serviceList.find((s) => s.id === id)?.name)
606
- .filter(Boolean) as string[]);
607
- const firstServiceId = offer.service_ids?.[0];
608
- const firstService = firstServiceId ? serviceList.find((s) => s.id === firstServiceId) : undefined;
609
- const first_service_description_markdown =
610
- firstService?.description_markdown || firstService?.summary || null;
611
- return { ...offer, category_names: category_names ?? [], first_service_description_markdown };
612
- }),
613
- [offerList, serviceList]
614
- );
615
-
616
561
  const serviceItemsForMenu: ServiceItemWithService[] = serviceList.flatMap((s) =>
617
562
  (s.service_items || []).map((si) => ({
618
563
  ...si,
@@ -621,12 +566,11 @@ export function ServiceMenuSection({
621
566
  service_description_markdown: s.description_markdown,
622
567
  }))
623
568
  );
624
- const hasAny = offersForMenu.length > 0 || packagesForMenu.length > 0 || serviceItemsForMenu.length > 0;
569
+ const hasAny = packagesForMenu.length > 0 || serviceItemsForMenu.length > 0;
625
570
 
626
571
  const CAROUSEL_MIN = 3;
627
572
  // Page variant: always grid (full list, no carousel). Section variant: carousel when >=3 items else grid.
628
573
  const isPage = variant === 'page';
629
- const offersLayout: 'carousel' | 'grid' = isPage ? 'grid' : (offersForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
630
574
  const packagesLayout: 'carousel' | 'grid' = isPage ? 'grid' : (packagesForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
631
575
  const serviceItemsLayout: 'carousel' | 'grid' = isPage ? 'grid' : (serviceItemsForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
632
576
 
@@ -648,40 +592,13 @@ export function ServiceMenuSection({
648
592
 
649
593
  const closeModal = useCallback(() => setDetailItem(null), []);
650
594
 
651
- const renderOfferCard = useCallback(
652
- (offer: OfferForMenu, index: number) => {
653
- const categorySubtitle =
654
- offer.category_names?.length ? offer.category_names.join(' | ') : (offer.value_terms ?? null);
655
- const cardDesc = offer.description || offer.first_service_description_markdown;
656
- const plainDesc = cardDesc ? (typeof cardDesc === 'string' ? cardDesc : '').replace(/[#*`\[\]]/g, '').replace(/\n+/g, ' ').trim() : '';
657
- const descText = plainDesc.length > 120 ? plainDesc.slice(0, 120) + '…' : plainDesc;
658
- return (
659
- <GridCardWithImage
660
- photoAttachments={offer.photo_attachments}
661
- fallbackId={offer.id}
662
- fallbackAlt={offer.name}
663
- title={offer.name}
664
- subtitle={categorySubtitle}
665
- onClick={() => setDetailItem({ type: 'offer', item: offer })}
666
- websitePhotos={websitePhotos}
667
- companyInformation={companyInformation}
668
- cycleSeed={`offer-${offer.id}-${index}`}
669
- >
670
- {descText && (
671
- <p className="font-body text-sm text-tertiary mt-1 line-clamp-2">{descText}</p>
672
- )}
673
- </GridCardWithImage>
674
- );
675
- },
676
- [websitePhotos, companyInformation]
677
- );
678
-
679
595
  const renderPackageCard = useCallback(
680
596
  (pkg: PackageForMenu, index: number) => {
681
597
  const categorySubtitle = pkg.category_names?.length ? pkg.category_names.join(' | ') : null;
682
598
  const cardDesc = pkg.description_markdown || pkg.summary || pkg.first_service_description_markdown;
683
599
  const plainDesc = cardDesc ? cardDesc.replace(/[#*`\[\]]/g, '').replace(/\n+/g, ' ').trim() : '';
684
600
  const descText = plainDesc.length > 120 ? plainDesc.slice(0, 120) + '…' : plainDesc;
601
+ const hasSpecial = getActivePublicOffers(pkg.offers).length > 0;
685
602
  return (
686
603
  <GridCardWithImage
687
604
  photoAttachments={pkg.photo_attachments}
@@ -693,6 +610,7 @@ export function ServiceMenuSection({
693
610
  websitePhotos={websitePhotos}
694
611
  companyInformation={companyInformation}
695
612
  cycleSeed={`pkg-${pkg.id}-${index}`}
613
+ hasSpecial={hasSpecial}
696
614
  >
697
615
  {descText && (
698
616
  <p className="font-body text-sm text-tertiary mt-1 line-clamp-2">{descText}</p>
@@ -715,6 +633,7 @@ export function ServiceMenuSection({
715
633
  websitePhotos={websitePhotos}
716
634
  companyInformation={companyInformation}
717
635
  cycleSeed={`service-item-${si.id}-${index}`}
636
+ hasSpecial={getActivePublicOffers(si.offers).length > 0}
718
637
  >
719
638
  {formatPriceCents(si.price_cents) && (
720
639
  <p className="font-body text-sm font-medium text-fg-primary mt-1">
@@ -760,13 +679,6 @@ export function ServiceMenuSection({
760
679
  </div>
761
680
  )}
762
681
 
763
- <MenuBlock
764
- rowTitle="Offers"
765
- items={offersForMenu}
766
- layout={offersLayout}
767
- renderItem={renderOfferCard}
768
- />
769
-
770
682
  <MenuBlock
771
683
  rowTitle="Packages"
772
684
  items={packagesForMenu}
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import type { CompanyInformation } from '../types/api/company-information';
7
- import type { PhotoAttachment } from '../types/api/photos';
8
7
  import type { Service } from '../types/api/service';
9
8
  import type { WebsitePhotos } from '../types/api/website-photos';
10
9
 
@@ -150,56 +149,3 @@ export async function getForm(formType: string) {
150
149
  return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
151
150
  }
152
151
 
153
- /** Minimal service for offer detail (from GET /public/offers/:id). */
154
- export interface OfferServiceSummary {
155
- id: number;
156
- name: string;
157
- slug: string;
158
- summary?: string | null;
159
- }
160
-
161
- /** Package summary (nested on each offer in list and single-offer responses). */
162
- export interface OfferPackageSummary {
163
- id: number;
164
- name: string;
165
- slug: string;
166
- summary?: string | null;
167
- description_markdown?: string | null;
168
- }
169
-
170
- /** Offer from public API. */
171
- export interface OfferPublic {
172
- id: number;
173
- name: string;
174
- description: string | null;
175
- value_terms: string | null;
176
- /** Optional; when absent or null, offer has no expiration. */
177
- expires_at?: string | null;
178
- expired?: boolean;
179
- /** Service IDs (used for category_names fallback when not provided by API). */
180
- service_ids?: number[];
181
- /** Package IDs (included in list response; nested services/packages are the source of truth). */
182
- package_ids?: number[];
183
- /** Category names from API (services + service_items + packages). */
184
- category_names?: string[];
185
- /** Photo attachments from related services (same shape as Service.photo_attachments). */
186
- photo_attachments?: PhotoAttachment[];
187
- /** Present when fetching single offer (GET /public/offers/:id). */
188
- services?: OfferServiceSummary[];
189
- /** Specific menu items this offer applies to (single offer only). */
190
- service_items?: OfferServiceSummary[];
191
- /** Packages this offer applies to (single offer only). */
192
- packages?: OfferPackageSummary[];
193
- }
194
-
195
- /** List offers for the current account (API key scoped). */
196
- export async function getOffers(): Promise<OfferPublic[] | null> {
197
- const data = await serverFetch<OfferPublic[]>('/public/offers', defaultOptions);
198
- return Array.isArray(data) ? data : null;
199
- }
200
-
201
- /** Single offer with services for detail page. */
202
- export async function getOffer(id: number | string): Promise<OfferPublic | null> {
203
- return serverFetch<OfferPublic>(`/public/offers/${id}`, defaultOptions);
204
- }
205
-
@@ -0,0 +1,13 @@
1
+ import type { PhotoAttachment } from './photos';
2
+
3
+ /** Nested under `service_items[].offers` and `packages[].offers` in public API. */
4
+ export interface OfferPublic {
5
+ id: number;
6
+ name: string;
7
+ description: string | null;
8
+ value_terms: string | null;
9
+ active: boolean;
10
+ expires_at?: string | null;
11
+ expired?: boolean;
12
+ photo_attachments?: PhotoAttachment[];
13
+ }
@@ -46,6 +46,8 @@ export interface ServiceItem {
46
46
  id: number;
47
47
  photo?: { thumbnail_url?: string; medium_url?: string; large_url?: string; original_url?: string; title?: string; alt_text?: string };
48
48
  }>;
49
+ /** Active offers that apply to this menu item (public API). */
50
+ offers?: import('./offer').OfferPublic[];
49
51
  }
50
52
 
51
53
  export interface ServiceParams {
@@ -14,6 +14,7 @@ export * from './api/form';
14
14
  export * from './api/faq';
15
15
  export * from './api/job-posting';
16
16
  export * from './api/location';
17
+ export * from './api/offer';
17
18
  export * from './api/photos';
18
19
  export * from './api/service';
19
20
  export * from './api/social-post';