keystone-design-bootstrap 1.0.48 → 1.0.49

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.
Files changed (35) hide show
  1. package/dist/{blog-post-CvRhU9ss.d.ts → blog-post-D7HFCDp1.d.ts} +2 -2
  2. package/dist/design_system/elements/index.d.ts +25 -1
  3. package/dist/design_system/elements/index.js +103 -3
  4. package/dist/design_system/elements/index.js.map +1 -1
  5. package/dist/design_system/sections/index.d.ts +64 -3
  6. package/dist/design_system/sections/index.js +1305 -458
  7. package/dist/design_system/sections/index.js.map +1 -1
  8. package/dist/form-BLZuTGkr.d.ts +137 -0
  9. package/dist/index.d.ts +6 -5
  10. package/dist/index.js +1335 -487
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/server-api.d.ts +49 -4
  13. package/dist/lib/server-api.js +17 -0
  14. package/dist/lib/server-api.js.map +1 -1
  15. package/dist/photos-8jMeetqV.d.ts +47 -0
  16. package/dist/types/index.d.ts +6 -5
  17. package/dist/utils/photo-helpers.d.ts +6 -15
  18. package/dist/utils/photo-helpers.js +10 -1
  19. package/dist/utils/photo-helpers.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/design_system/elements/index.tsx +4 -0
  22. package/src/design_system/elements/modal/modal.tsx +129 -0
  23. package/src/design_system/sections/index.tsx +20 -2
  24. package/src/design_system/sections/offer-detail.tsx +46 -0
  25. package/src/design_system/sections/offers-gallery.tsx +40 -0
  26. package/src/design_system/sections/offers-grid.tsx +108 -0
  27. package/src/design_system/sections/offers-section.tsx +90 -0
  28. package/src/design_system/sections/service-menu-section.tsx +813 -0
  29. package/src/lib/server-api.ts +63 -0
  30. package/src/types/api/photos.ts +11 -10
  31. package/src/types/api/service.ts +21 -0
  32. package/src/utils/photo-helpers.ts +3 -14
  33. package/dist/company-information-C_k_sLSB.d.ts +0 -46
  34. package/dist/form-CWXC-IHT.d.ts +0 -88
  35. package/dist/website-photos-_n2g24IM.d.ts +0 -20
@@ -1,6 +1,5 @@
1
- import { S as Service, F as FormDefinition } from '../form-CWXC-IHT.js';
2
- import { C as CompanyInformation } from '../company-information-C_k_sLSB.js';
3
- import { W as WebsitePhotos } from '../website-photos-_n2g24IM.js';
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';
4
3
 
5
4
  interface FetchOptions {
6
5
  cache?: RequestCache;
@@ -34,8 +33,54 @@ declare function getWebsitePhotos(): Promise<WebsitePhotos | null>;
34
33
  declare function getJobPostings(): Promise<unknown>;
35
34
  declare function getJobPosting(slug: string): Promise<unknown>;
36
35
  declare function getSocialPosts(): Promise<unknown>;
36
+ /** Packages (bundles of service items). */
37
+ declare function getPackages(): Promise<unknown>;
38
+ declare function getPackage(slug: string): Promise<unknown>;
37
39
  declare function getTestimonials(): Promise<unknown>;
38
40
  /** Form definition for dynamic form rendering (fields may include optional placeholder). */
39
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>;
40
85
 
41
- export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
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 };
@@ -84,12 +84,25 @@ async function getJobPosting(slug) {
84
84
  async function getSocialPosts() {
85
85
  return serverFetch("/public/social_posts", defaultOptions);
86
86
  }
87
+ async function getPackages() {
88
+ return serverFetch("/public/packages", defaultOptions);
89
+ }
90
+ async function getPackage(slug) {
91
+ return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);
92
+ }
87
93
  async function getTestimonials() {
88
94
  return getReviews();
89
95
  }
90
96
  async function getForm(formType) {
91
97
  return serverFetch(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
92
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
+ }
93
106
  export {
94
107
  getAdsConfig,
95
108
  getBlogPost,
@@ -102,6 +115,10 @@ export {
102
115
  getLocation,
103
116
  getLocations,
104
117
  getMetaPixelId,
118
+ getOffer,
119
+ getOffers,
120
+ getPackage,
121
+ getPackages,
105
122
  getReviews,
106
123
  getService,
107
124
  getServices,
@@ -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 { 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// 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":";AASA,IAAM,UAAU,QAAQ,IAAI,WAAW;AACvC,IAAM,UAAU,QAAQ,IAAI,WAAW;AAOvC,eAAe,YAAe,UAAkB,UAAwB,CAAC,GAAsB;AAjB/F;AAkBE,QAAM,MAAM,GAAG,OAAO,GAAG,QAAQ;AAEjC,MAAI;AACF,UAAM,eAAiE;AAAA,MACrE,SAAS;AAAA,QACP,aAAa;AAAA,QACb,gBAAgB;AAAA,MAClB;AAAA,MACA,OAAO,QAAQ;AAAA,IACjB;AAEA,QAAI,QAAQ,YAAY;AACtB,mBAAa,OAAO,EAAE,YAAY,QAAQ,WAAW;AAAA,IACvD;AAEA,UAAM,WAAW,MAAM,MAAM,KAAK,YAAY;AAE9C,QAAI,CAAC,SAAS,IAAI;AAChB,cAAQ,MAAM,sBAAsB,SAAS,MAAM,QAAQ,QAAQ,EAAE;AACrE,aAAO;AAAA,IACT;AAEA,UAAM,OAAO,MAAM,SAAS,KAAK;AAGjC,YAAO,UAAK,SAAL,YAAa;AAAA,EACtB,SAAS,OAAO;AACd,YAAQ,MAAM,gCAAgC,QAAQ,KAAK,KAAK;AAChE,WAAO;AAAA,EACT;AACF;AAKO,IAAM,YAAY;AAAA,EACvB,KAAK,CAAc,UAAkB,YAA8C;AACjF,WAAO,YAAe,UAAU,WAAW,EAAE,YAAY,GAAG,CAAC;AAAA,EAC/D;AACF;AAGA,IAAM,iBAA+B,EAAE,YAAY,GAAG;AAEtD,eAAsB,wBAA4D;AAChF,SAAO,YAAgC,+BAA+B,cAAc;AACtF;AAGA,eAAsB,eAA2D;AAC/E,QAAM,OAAO,MAAM,YAAwC,sBAAsB,cAAc;AAC/F,SAAO,sBAAQ;AACjB;AAGO,SAAS,eAAe,WAAyE;AACtG,QAAM,KAAK,aAAa,OAAO,cAAc,YAAY,mBAAmB,aAAa,UAAU;AACnG,QAAM,MAAM,MAAM,QAAQ,OAAO,KAAK,OAAO,EAAE,EAAE,KAAK,IAAI;AAC1D,SAAO,QAAQ,MAAM,QAAQ,UAAU,QAAQ,KAAK,GAAG,IAAI,MAAM;AACnE;AAEA,eAAsB,cAAc;AAClC,SAAO,YAAY,oBAAoB,cAAc;AACvD;AAEA,eAAsB,WAAW,MAAuC;AACtE,SAAO,YAAqB,4BAA4B,IAAI,IAAI,cAAc;AAChF;AAEA,eAAsB,eAAe;AACnC,SAAO,YAAY,qBAAqB,cAAc;AACxD;AAEA,eAAsB,YAAY,MAAc;AAC9C,SAAO,YAAY,6BAA6B,IAAI,IAAI,cAAc;AACxE;AAEA,eAAsB,aAAa;AACjC,SAAO,YAAY,mBAAmB,cAAc;AACtD;AAEA,eAAsB,UAAU;AAC9B,SAAO,YAAY,yBAAyB,cAAc;AAC5D;AAEA,eAAsB,eAAe;AACnC,SAAO,YAAY,sBAAsB,cAAc;AACzD;AAEA,eAAsB,YAAY,MAAc;AAC9C,SAAO,YAAY,8BAA8B,IAAI,IAAI,cAAc;AACzE;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAEA,eAAsB,mBAAkD;AACtE,SAAO,YAA2B,0BAA0B,cAAc;AAC5E;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAEA,eAAsB,cAAc,MAAc;AAChD,SAAO,YAAY,gCAAgC,IAAI,IAAI,cAAc;AAC3E;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAGA,eAAsB,kBAAkB;AACtC,SAAO,WAAW;AACpB;AAGA,eAAsB,QAAQ,UAAkB;AAE9C,SAAO,YAA4B,iBAAiB,mBAAmB,QAAQ,CAAC,IAAI,cAAc;AACpG;","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 { 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":[]}
@@ -0,0 +1,47 @@
1
+ interface WebsitePhoto {
2
+ id: number;
3
+ url: string;
4
+ thumbnail_url?: string;
5
+ medium_url?: string;
6
+ alt: string;
7
+ source: 'account' | 'industry';
8
+ }
9
+ interface WebsitePhotos {
10
+ logo?: WebsitePhoto | null;
11
+ favicon?: WebsitePhoto | null;
12
+ hero?: WebsitePhoto | null;
13
+ contact?: WebsitePhoto | null;
14
+ about?: WebsitePhoto | null;
15
+ careers?: WebsitePhoto | null;
16
+ preview_image?: WebsitePhoto | null;
17
+ stock_photos?: WebsitePhoto[];
18
+ }
19
+ type WebsitePhotosResponse = WebsitePhotos;
20
+
21
+ /**
22
+ * Photo and PhotoAttachment type definitions
23
+ */
24
+ interface Photo {
25
+ id?: number;
26
+ title?: string;
27
+ description?: string;
28
+ alt_text?: string;
29
+ thumbnail_url?: string;
30
+ medium_url?: string;
31
+ large_url?: string;
32
+ original_url?: string;
33
+ generated?: boolean;
34
+ created_at?: string;
35
+ updated_at?: string;
36
+ }
37
+ interface PhotoAttachment {
38
+ id: number;
39
+ /** May be omitted in some API responses. */
40
+ photo?: Photo;
41
+ featured?: boolean;
42
+ sort_order?: number;
43
+ created_at?: string;
44
+ updated_at?: string;
45
+ }
46
+
47
+ export type { PhotoAttachment as P, WebsitePhotos as W, Photo as a, WebsitePhoto as b, WebsitePhotosResponse as c };
@@ -1,9 +1,8 @@
1
1
  import { Theme } from '../themes/index.js';
2
- export { B as BlogPost, a as BlogPostAuthor, c as BlogPostParams, d as BlogPostResponse, b as BlogPostTag } from '../blog-post-CvRhU9ss.js';
3
- export { C as CompanyInformation, a as CompanyInformationResponse } from '../company-information-C_k_sLSB.js';
4
- import { a as PhotoAttachment } from '../form-CWXC-IHT.js';
5
- export { F as FormDefinition, b as FormFieldDefinition, c as FormFieldItem, d as FormType, P as Photo, S as Service, e as ServiceParams, f as ServiceResponse } from '../form-CWXC-IHT.js';
6
- export { a as WebsitePhoto, W as WebsitePhotos, b as WebsitePhotosResponse } from '../website-photos-_n2g24IM.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';
7
6
 
8
7
  interface NavItem {
9
8
  label: string;
@@ -115,6 +114,8 @@ interface SocialPost {
115
114
  updated_at: string;
116
115
  /** Image URLs from photo_attachments (preferred for display) */
117
116
  image_urls?: string[];
117
+ /** Video URLs from photo_attachments (exclude these when choosing img src) */
118
+ video_urls?: string[];
118
119
  /** Photo attachments (same pattern as blog post, team member, etc.) */
119
120
  photo_attachments?: PhotoAttachment[];
120
121
  /** Legacy; prefer image_urls / photo_attachments */
@@ -1,22 +1,13 @@
1
- import { W as WebsitePhotos } from '../website-photos-_n2g24IM.js';
1
+ import { P as PhotoAttachment, W as WebsitePhotos } from '../photos-8jMeetqV.js';
2
2
 
3
3
  /**
4
4
  * Helper functions for extracting photo URLs from photo associations
5
5
  */
6
6
 
7
- interface PhotoAttachment {
8
- id: number;
9
- photo: {
10
- id: number;
11
- title: string;
12
- thumbnail_url?: string;
13
- medium_url?: string;
14
- large_url?: string;
15
- original_url?: string;
16
- };
17
- featured: boolean;
18
- sort_order: number;
19
- }
7
+ /**
8
+ * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.
9
+ */
10
+ declare function isVideoUrl(url: string | null | undefined): boolean;
20
11
  /**
21
12
  * Get the best available photo URL from a photos array
22
13
  * Priority: featured photo > first photo > fallback
@@ -43,4 +34,4 @@ declare function getFeaturedImageUrl(photos?: PhotoAttachment[]): string | null;
43
34
  */
44
35
  declare function getLogoUrl(websitePhotos?: WebsitePhotos | null): string | undefined;
45
36
 
46
- export { type PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl };
37
+ export { PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl, isVideoUrl };
@@ -1,4 +1,12 @@
1
1
  // src/utils/photo-helpers.ts
2
+ var VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv", ".flv", ".wmv"];
3
+ function isVideoUrl(url) {
4
+ var _a;
5
+ if (!url || typeof url !== "string") return false;
6
+ const path = (_a = url.split("?")[0]) != null ? _a : "";
7
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
8
+ return VIDEO_EXTENSIONS.includes(ext);
9
+ }
2
10
  function getPhotoUrl(photos) {
3
11
  if (photos && photos.length > 0) {
4
12
  const featuredPhoto = photos.find((pa) => pa.featured);
@@ -27,6 +35,7 @@ export {
27
35
  getAvatarUrl,
28
36
  getFeaturedImageUrl,
29
37
  getLogoUrl,
30
- getPhotoUrl
38
+ getPhotoUrl,
39
+ isVideoUrl
31
40
  };
32
41
  //# sourceMappingURL=photo-helpers.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport interface PhotoAttachment {\n id: number;\n photo: {\n id: number;\n title: string;\n thumbnail_url?: string;\n medium_url?: string;\n large_url?: string;\n original_url?: string;\n };\n featured: boolean;\n sort_order: number;\n}\n\n/**\n * Get the best available photo URL from a photos array\n * Priority: featured photo > first photo > fallback\n */\nexport function getPhotoUrl(\n photos?: PhotoAttachment[]\n): string | null {\n // Priority 1: Featured photo from photos array\n if (photos && photos.length > 0) {\n const featuredPhoto = photos.find(pa => pa.featured);\n const photoToUse = featuredPhoto || photos[0];\n const photo = photoToUse.photo;\n \n if (photo) {\n return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;\n }\n }\n \n // Fallback (gradient or null)\n return null;\n}\n\n/**\n * Get avatar URL for team members or authors.\n * Returns a URL only when there is a real photo in attachments; otherwise null.\n * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.\n */\n/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */\n/* eslint-disable @typescript-eslint/no-unused-vars -- _fallbackId, _name reserved for API */\nexport function getAvatarUrl(\n photos?: PhotoAttachment[],\n _fallbackId?: number | string,\n _name?: string\n): string | null {\n return getPhotoUrl(photos);\n}\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\n/**\n * Get featured image URL for blog posts\n */\nexport function getFeaturedImageUrl(\n photos?: PhotoAttachment[]\n): string | null {\n return getPhotoUrl(photos);\n}\n\n/**\n * Get logo URL from website_photos API (which aggregates from account_photos)\n * \n * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',\n * with industry fallback. This is the primary and only source for logos.\n * \n * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)\n */\nexport function getLogoUrl(\n websitePhotos?: WebsitePhotos | null\n): string | undefined {\n if (websitePhotos?.logo?.url) {\n return websitePhotos.logo.url;\n }\n \n return undefined;\n}\n\n"],"mappings":";AAwBO,SAAS,YACd,QACe;AAEf,MAAI,UAAU,OAAO,SAAS,GAAG;AAC/B,UAAM,gBAAgB,OAAO,KAAK,QAAM,GAAG,QAAQ;AACnD,UAAM,aAAa,iBAAiB,OAAO,CAAC;AAC5C,UAAM,QAAQ,WAAW;AAEzB,QAAI,OAAO;AACT,aAAO,MAAM,aAAa,MAAM,cAAc,MAAM,iBAAiB,MAAM,gBAAgB;AAAA,IAC7F;AAAA,EACF;AAGA,SAAO;AACT;AASO,SAAS,aACd,QACA,aACA,OACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAMO,SAAS,oBACd,QACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAUO,SAAS,WACd,eACoB;AA7EtB;AA8EE,OAAI,oDAAe,SAAf,mBAAqB,KAAK;AAC5B,WAAO,cAAc,KAAK;AAAA,EAC5B;AAEA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport type { PhotoAttachment } from '../types/api/photos';\n\n/** Video file extensions treated as video (match backend ExternalPhotoService.video_url?) */\nconst VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.m4v', '.avi', '.mkv', '.flv', '.wmv'];\n\n/**\n * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.\n */\nexport function isVideoUrl(url: string | null | undefined): boolean {\n if (!url || typeof url !== 'string') return false;\n const path = url.split('?')[0] ?? '';\n const ext = path.slice(path.lastIndexOf('.')).toLowerCase();\n return VIDEO_EXTENSIONS.includes(ext);\n}\n\n/**\n * Get the best available photo URL from a photos array\n * Priority: featured photo > first photo > fallback\n */\nexport function getPhotoUrl(\n photos?: PhotoAttachment[]\n): string | null {\n // Priority 1: Featured photo from photos array\n if (photos && photos.length > 0) {\n const featuredPhoto = photos.find(pa => pa.featured);\n const photoToUse = featuredPhoto || photos[0];\n const photo = photoToUse.photo;\n \n if (photo) {\n return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;\n }\n }\n \n // Fallback (gradient or null)\n return null;\n}\n\n/**\n * Get avatar URL for team members or authors.\n * Returns a URL only when there is a real photo in attachments; otherwise null.\n * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.\n */\n/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */\n/* eslint-disable @typescript-eslint/no-unused-vars -- _fallbackId, _name reserved for API */\nexport function getAvatarUrl(\n photos?: PhotoAttachment[],\n _fallbackId?: number | string,\n _name?: string\n): string | null {\n return getPhotoUrl(photos);\n}\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\n/**\n * Get featured image URL for blog posts\n */\nexport function getFeaturedImageUrl(\n photos?: PhotoAttachment[]\n): string | null {\n return getPhotoUrl(photos);\n}\n\n/**\n * Get logo URL from website_photos API (which aggregates from account_photos)\n * \n * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',\n * with industry fallback. This is the primary and only source for logos.\n * \n * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)\n */\nexport function getLogoUrl(\n websitePhotos?: WebsitePhotos | null\n): string | undefined {\n if (websitePhotos?.logo?.url) {\n return websitePhotos.logo.url;\n }\n \n return undefined;\n}\n\n"],"mappings":";AAUA,IAAM,mBAAmB,CAAC,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ,MAAM;AAKlF,SAAS,WAAW,KAAyC;AAfpE;AAgBE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,QAAO,SAAI,MAAM,GAAG,EAAE,CAAC,MAAhB,YAAqB;AAClC,QAAM,MAAM,KAAK,MAAM,KAAK,YAAY,GAAG,CAAC,EAAE,YAAY;AAC1D,SAAO,iBAAiB,SAAS,GAAG;AACtC;AAMO,SAAS,YACd,QACe;AAEf,MAAI,UAAU,OAAO,SAAS,GAAG;AAC/B,UAAM,gBAAgB,OAAO,KAAK,QAAM,GAAG,QAAQ;AACnD,UAAM,aAAa,iBAAiB,OAAO,CAAC;AAC5C,UAAM,QAAQ,WAAW;AAEzB,QAAI,OAAO;AACT,aAAO,MAAM,aAAa,MAAM,cAAc,MAAM,iBAAiB,MAAM,gBAAgB;AAAA,IAC7F;AAAA,EACF;AAGA,SAAO;AACT;AASO,SAAS,aACd,QACA,aACA,OACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAMO,SAAS,oBACd,QACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAUO,SAAS,WACd,eACoB;AA/EtB;AAgFE,OAAI,oDAAe,SAAf,mBAAqB,KAAK;AAC5B,WAAO,cAAc,KAAK;AAAA,EAC5B;AAEA,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.48",
3
+ "version": "1.0.49",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -132,3 +132,7 @@ export { ComboBox } from './select/combobox';
132
132
  // Video components (no theming needed)
133
133
  export { VideoModal } from './video-modal';
134
134
  export { VideoPlayButton } from './video-play-button';
135
+
136
+ // Shared modal for detail views, confirmations, etc.
137
+ export { Modal } from './modal/modal';
138
+ export type { ModalProps } from './modal/modal';
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef } from 'react';
4
+
5
+ export interface ModalProps {
6
+ /** Whether the modal is open */
7
+ isOpen: boolean;
8
+ /** Called when the user requests close (button, Escape, or overlay click) */
9
+ onClose: () => void;
10
+ /** Optional title for the dialog (used for aria-labelledby) */
11
+ title?: string;
12
+ /** Optional id for the title element (must be set if title is set for a11y) */
13
+ titleId?: string;
14
+ /** Dialog content */
15
+ children: React.ReactNode;
16
+ /** Optional className for the overlay (backdrop) */
17
+ overlayClassName?: string;
18
+ /** Optional className for the dialog panel */
19
+ panelClassName?: string;
20
+ /** Optional max width class for the panel (default: max-w-md). Use max-w-lg, max-w-2xl, etc. */
21
+ maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
22
+ }
23
+
24
+ const MAX_WIDTH_CLASSES: Record<NonNullable<ModalProps['maxWidth']>, string> = {
25
+ sm: 'max-w-sm',
26
+ md: 'max-w-md',
27
+ lg: 'max-w-lg',
28
+ xl: 'max-w-xl',
29
+ '2xl': 'max-w-2xl',
30
+ '3xl': 'max-w-3xl',
31
+ '4xl': 'max-w-4xl',
32
+ '5xl': 'max-w-5xl',
33
+ };
34
+
35
+ /**
36
+ * Shared modal (dialog) component. Accessible: focus trap, Escape to close, aria-modal, overlay click to close.
37
+ * Use for detail views, confirmations, and other overlay content. Renders in place (use a portal from the consumer if needed).
38
+ */
39
+ export function Modal({
40
+ isOpen,
41
+ onClose,
42
+ title,
43
+ titleId = 'modal-title',
44
+ children,
45
+ overlayClassName,
46
+ panelClassName,
47
+ maxWidth = 'md',
48
+ }: ModalProps) {
49
+ const overlayRef = useRef<HTMLDivElement>(null);
50
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
51
+
52
+ useEffect(() => {
53
+ if (!isOpen) return;
54
+ const previouslyFocused = document.activeElement as HTMLElement | null;
55
+ closeButtonRef.current?.focus();
56
+ const handleEscape = (e: KeyboardEvent) => {
57
+ if (e.key === 'Escape') onClose();
58
+ };
59
+ document.addEventListener('keydown', handleEscape);
60
+ document.body.style.overflow = 'hidden';
61
+ return () => {
62
+ document.removeEventListener('keydown', handleEscape);
63
+ document.body.style.overflow = '';
64
+ previouslyFocused?.focus();
65
+ };
66
+ }, [isOpen, onClose]);
67
+
68
+ if (!isOpen) return null;
69
+
70
+ const maxWidthClass = MAX_WIDTH_CLASSES[maxWidth];
71
+
72
+ return (
73
+ <div
74
+ ref={overlayRef}
75
+ className={
76
+ overlayClassName ??
77
+ 'fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/50'
78
+ }
79
+ role="dialog"
80
+ aria-modal="true"
81
+ aria-labelledby={title ? titleId : undefined}
82
+ onClick={(e) => e.target === overlayRef.current && onClose()}
83
+ >
84
+ <div
85
+ className={
86
+ panelClassName ??
87
+ `bg-primary border border-secondary rounded-lg shadow-xl w-full overflow-hidden ${maxWidthClass} max-h-[90vh] flex flex-col`
88
+ }
89
+ onClick={(e) => e.stopPropagation()}
90
+ >
91
+ <div className="flex items-start justify-between gap-4 p-4 md:p-6 border-b border-secondary flex-shrink-0">
92
+ {title ? (
93
+ <h2
94
+ id={titleId}
95
+ className="font-display text-lg font-normal text-fg-primary md:text-xl flex-1 min-w-0"
96
+ >
97
+ {title}
98
+ </h2>
99
+ ) : (
100
+ <span className="flex-1" aria-hidden />
101
+ )}
102
+ <button
103
+ ref={closeButtonRef}
104
+ type="button"
105
+ onClick={onClose}
106
+ className="shrink-0 p-1 text-fg-primary hover:text-brand-accent rounded focus:outline-none focus:ring-2 focus:ring-brand-accent"
107
+ aria-label="Close"
108
+ >
109
+ <svg
110
+ className="w-5 h-5"
111
+ fill="none"
112
+ stroke="currentColor"
113
+ viewBox="0 0 24 24"
114
+ aria-hidden
115
+ >
116
+ <path
117
+ strokeLinecap="round"
118
+ strokeLinejoin="round"
119
+ strokeWidth={2}
120
+ d="M6 18L18 6M6 6l12 12"
121
+ />
122
+ </svg>
123
+ </button>
124
+ </div>
125
+ <div className="overflow-y-auto flex-1 p-4 md:p-6">{children}</div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -46,7 +46,9 @@ 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
-
49
+ import { OffersSection as BaseOffersSection } from './offers-section';
50
+ import { OffersGallery as BaseOffersGallery } from './offers-gallery';
51
+ import { OfferDetailSection as BaseOfferDetailSection } from './offer-detail';
50
52
  // Import variant files to trigger their registration
51
53
  // Aman theme variants
52
54
  import './hero-home.aman';
@@ -92,7 +94,7 @@ import './social-media-grid.barelux';
92
94
  import './contact-section.barelux';
93
95
  import './footer-home.barelux';
94
96
 
95
- // Balance theme variants
97
+ // Balance theme variants (offers-section uses base for balance)
96
98
  import './hero-home.balance';
97
99
  import './header-navigation.balance';
98
100
  import './footer-home.balance';
@@ -179,5 +181,21 @@ export const HomeHeroComponent = createThemedExport('home-hero-component', BaseH
179
181
  // Re-export application form (client component, no theme variants)
180
182
  export { JobApplicationForm } from './job-application-form';
181
183
 
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
197
+ export { ServiceMenuSection } from './service-menu-section';
198
+ export type { ServiceMenuSectionProps, PackagePublic } from './service-menu-section';
199
+
182
200
  // Re-export types
183
201
  export type { Theme } from '../../themes';
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { registerThemeVariant } from '../../lib/component-registry';
5
+ import { Button } from '../elements';
6
+ import type { OfferPublic } from '../../lib/server-api';
7
+
8
+ interface OfferDetailSectionProps {
9
+ offer?: OfferPublic | null;
10
+ websitePhotos?: unknown;
11
+ companyInformation?: unknown;
12
+ }
13
+
14
+ /** Default/fallback offer detail when no theme variant overrides. */
15
+ export const OfferDetailSection = ({ offer }: OfferDetailSectionProps) => {
16
+ if (!offer) {
17
+ return (
18
+ <div className="text-center py-12">
19
+ <div className="text-6xl mb-4">🎁</div>
20
+ <h3 className="text-xl font-semibold text-gray-900 mb-2">Offer Not Found</h3>
21
+ <p className="text-gray-600 mb-4">The offer you&apos;re looking for doesn&apos;t exist or has expired.</p>
22
+ <Button href="/offers">View All Offers</Button>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <div className="mx-auto max-w-3xl px-4 py-12">
29
+ <h1 className="text-2xl font-semibold text-gray-900 mb-4">{offer.name}</h1>
30
+ {offer.value_terms && <p className="text-gray-600 mb-2">{offer.value_terms}</p>}
31
+ {offer.description && <p className="text-gray-600 mb-4">{offer.description}</p>}
32
+ {offer.expires_at && (() => {
33
+ const d = new Date(offer.expires_at);
34
+ if (Number.isNaN(d.getTime())) return null;
35
+ return (
36
+ <p className="text-sm text-gray-500 mb-6">
37
+ Expires {d.toLocaleDateString()}
38
+ </p>
39
+ );
40
+ })()}
41
+ <Button href="/offers">Back to Offers</Button>
42
+ </div>
43
+ );
44
+ };
45
+
46
+ registerThemeVariant('offer-detail', 'classic', OfferDetailSection);
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import React from 'react';
4
+ import { registerThemeVariant } from '../../lib/component-registry';
5
+ import { OffersGrid } from './offers-grid';
6
+ import type { OfferPublic } from '../../lib/server-api';
7
+ import type { WebsitePhotos } from '../../types/api/website-photos';
8
+ import type { CompanyInformation } from '../../types/api/company-information';
9
+
10
+ interface OffersGalleryProps {
11
+ offers?: OfferPublic[] | null;
12
+ title?: string;
13
+ subtitle?: string;
14
+ offersPerPage?: number;
15
+ className?: string;
16
+ websitePhotos?: WebsitePhotos | null;
17
+ companyInformation?: CompanyInformation | null;
18
+ }
19
+
20
+ /** Default/fallback: use grid layout when no theme variant overrides. */
21
+ export const OffersGallery = ({
22
+ offers,
23
+ title = 'Offers',
24
+ subtitle = 'See our current offers.',
25
+ websitePhotos,
26
+ companyInformation,
27
+ className = '',
28
+ }: OffersGalleryProps) => (
29
+ <section className={className}>
30
+ <OffersGrid
31
+ offers={offers}
32
+ title={title}
33
+ subtitle={subtitle}
34
+ websitePhotos={websitePhotos}
35
+ companyInformation={companyInformation}
36
+ />
37
+ </section>
38
+ );
39
+
40
+ registerThemeVariant('offers-gallery', 'classic', OffersGallery);