keystone-design-bootstrap 1.0.50 → 1.0.53

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 (32) hide show
  1. package/dist/{blog-post-D7HFCDp1.d.ts → blog-post-DGjaJ3wf.d.ts} +2 -2
  2. package/dist/design_system/sections/index.d.ts +7 -25
  3. package/dist/design_system/sections/index.js +257 -405
  4. package/dist/design_system/sections/index.js.map +1 -1
  5. package/dist/{form-BLZuTGkr.d.ts → form-CpsCONG5.d.ts} +16 -2
  6. package/dist/index.d.ts +4 -5
  7. package/dist/index.js +275 -423
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/server-api.d.ts +3 -46
  10. package/dist/lib/server-api.js +0 -9
  11. package/dist/lib/server-api.js.map +1 -1
  12. package/dist/types/index.d.ts +4 -4
  13. package/dist/utils/photo-helpers.d.ts +1 -1
  14. package/package.json +1 -1
  15. package/src/design_system/sections/email-signup-section.tsx +115 -0
  16. package/src/design_system/sections/header-navigation.aman.tsx +8 -3
  17. package/src/design_system/sections/header-navigation.balance.tsx +2 -0
  18. package/src/design_system/sections/header-navigation.barelux.tsx +4 -1
  19. package/src/design_system/sections/index.tsx +5 -16
  20. package/src/design_system/sections/service-menu-section.tsx +58 -146
  21. package/src/lib/server-api.ts +0 -54
  22. package/src/next/contexts/form-definitions.tsx +5 -2
  23. package/src/next/layouts/root-layout.tsx +3 -0
  24. package/src/types/api/form.ts +1 -1
  25. package/src/types/api/offer.ts +13 -0
  26. package/src/types/api/service.ts +2 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/design_system/sections/offer-detail.tsx +0 -46
  29. package/src/design_system/sections/offers-gallery.tsx +0 -40
  30. package/src/design_system/sections/offers-grid.tsx +0 -108
  31. package/src/design_system/sections/offers-section.tsx +0 -90
  32. package/dist/{photos-8jMeetqV.d.ts → website-photos-Bm-CBK9g.d.ts} +20 -20
@@ -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
-
@@ -6,21 +6,24 @@ import type { FormDefinition } from '../../types/api/form';
6
6
  export type FormDefinitionsContextValue = {
7
7
  leadFormDefinition: FormDefinition | null;
8
8
  jobApplicationFormDefinition: FormDefinition | null;
9
+ marketingListSignupFormDefinition: FormDefinition | null;
9
10
  };
10
11
 
11
12
  const FormDefinitionsContext = createContext<FormDefinitionsContextValue>({
12
13
  leadFormDefinition: null,
13
14
  jobApplicationFormDefinition: null,
15
+ marketingListSignupFormDefinition: null,
14
16
  });
15
17
 
16
18
  export function FormDefinitionsProvider(props: {
17
19
  leadFormDefinition: FormDefinition | null;
18
20
  jobApplicationFormDefinition: FormDefinition | null;
21
+ marketingListSignupFormDefinition: FormDefinition | null;
19
22
  children: React.ReactNode;
20
23
  }) {
21
- const { leadFormDefinition, jobApplicationFormDefinition, children } = props;
24
+ const { leadFormDefinition, jobApplicationFormDefinition, marketingListSignupFormDefinition, children } = props;
22
25
  return (
23
- <FormDefinitionsContext.Provider value={{ leadFormDefinition, jobApplicationFormDefinition }}>
26
+ <FormDefinitionsContext.Provider value={{ leadFormDefinition, jobApplicationFormDefinition, marketingListSignupFormDefinition }}>
24
27
  {children}
25
28
  </FormDefinitionsContext.Provider>
26
29
  );
@@ -111,6 +111,7 @@ export async function KeystoneRootLayout(props: {
111
111
  teamMembersData,
112
112
  leadFormDefinition,
113
113
  jobApplicationFormDefinition,
114
+ marketingListSignupFormDefinition,
114
115
  adsConfig,
115
116
  ] =
116
117
  await Promise.all([
@@ -121,6 +122,7 @@ export async function KeystoneRootLayout(props: {
121
122
  getTeamMembers(),
122
123
  getForm('lead'),
123
124
  getForm('job_application'),
125
+ getForm('marketing_list_signup'),
124
126
  getAdsConfig(),
125
127
  ]);
126
128
 
@@ -162,6 +164,7 @@ export async function KeystoneRootLayout(props: {
162
164
  <FormDefinitionsProvider
163
165
  leadFormDefinition={leadFormDefinition ?? null}
164
166
  jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
167
+ marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
165
168
  >
166
169
  <HeaderNavigation
167
170
  config={dynamicConfig}
@@ -28,4 +28,4 @@ export interface FormDefinition {
28
28
  updated_at?: string;
29
29
  }
30
30
 
31
- export type FormType = 'lead' | 'job_application';
31
+ export type FormType = 'lead' | 'job_application' | 'marketing_list_signup';
@@ -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';
@@ -1,46 +0,0 @@
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);
@@ -1,40 +0,0 @@
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 ?? null}
32
- title={title}
33
- subtitle={subtitle}
34
- websitePhotos={websitePhotos}
35
- companyInformation={companyInformation}
36
- />
37
- </section>
38
- );
39
-
40
- registerThemeVariant('offers-gallery', 'classic', OffersGallery);