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.
- package/dist/{blog-post-D7HFCDp1.d.ts → blog-post-DGjaJ3wf.d.ts} +2 -2
- package/dist/design_system/sections/index.d.ts +7 -25
- package/dist/design_system/sections/index.js +257 -405
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/{form-BLZuTGkr.d.ts → form-CpsCONG5.d.ts} +16 -2
- package/dist/index.d.ts +4 -5
- package/dist/index.js +275 -423
- package/dist/index.js.map +1 -1
- package/dist/lib/server-api.d.ts +3 -46
- package/dist/lib/server-api.js +0 -9
- package/dist/lib/server-api.js.map +1 -1
- package/dist/types/index.d.ts +4 -4
- package/dist/utils/photo-helpers.d.ts +1 -1
- package/package.json +1 -1
- package/src/design_system/sections/email-signup-section.tsx +115 -0
- package/src/design_system/sections/header-navigation.aman.tsx +8 -3
- package/src/design_system/sections/header-navigation.balance.tsx +2 -0
- package/src/design_system/sections/header-navigation.barelux.tsx +4 -1
- package/src/design_system/sections/index.tsx +5 -16
- package/src/design_system/sections/service-menu-section.tsx +58 -146
- package/src/lib/server-api.ts +0 -54
- package/src/next/contexts/form-definitions.tsx +5 -2
- package/src/next/layouts/root-layout.tsx +3 -0
- package/src/types/api/form.ts +1 -1
- package/src/types/api/offer.ts +13 -0
- package/src/types/api/service.ts +2 -0
- package/src/types/index.ts +1 -0
- package/src/design_system/sections/offer-detail.tsx +0 -46
- package/src/design_system/sections/offers-gallery.tsx +0 -40
- package/src/design_system/sections/offers-grid.tsx +0 -108
- package/src/design_system/sections/offers-section.tsx +0 -90
- 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 '../../
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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}
|
package/src/lib/server-api.ts
CHANGED
|
@@ -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}
|
package/src/types/api/form.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/api/service.ts
CHANGED
|
@@ -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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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're looking for doesn'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);
|