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
@@ -0,0 +1,813 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { PhotoWithFallback, Carousel, Modal, MarkdownRenderer, Button } from '../elements';
6
+ import type { OfferPublic } from '../../lib/server-api';
7
+ import type { Service, ServiceItem } from '../../types/api/service';
8
+ import type { WebsitePhotos } from '../../types/api/website-photos';
9
+ import type { CompanyInformation } from '../../types/api/company-information';
10
+ import type { PhotoAttachment } from '../../types/api/photos';
11
+
12
+ const SERVICE_MENU_MODAL_ROOT_ID = 'service-menu-modal-root';
13
+ const CYCLE_INTERVAL_MIN_MS = 6000;
14
+ const CYCLE_INTERVAL_MAX_MS = 8000;
15
+
16
+ /** Returns a stable value in [0, 1) from seed (for per-card random interval). */
17
+ function seedToUnit(seed: string): number {
18
+ let h = 2166136261 >>> 0;
19
+ for (let i = 0; i < seed.length; i++) {
20
+ h ^= seed.charCodeAt(i);
21
+ h = (Math.imul(h, 16777619) >>> 0) >>> 0;
22
+ }
23
+ return (h >>> 0) / 4294967296;
24
+ }
25
+
26
+ /** Minimal package from public API (id, name, slug, summary, photo_attachments, description_markdown). */
27
+ export interface PackagePublic {
28
+ id: number;
29
+ name: string;
30
+ slug: string;
31
+ summary?: string | null;
32
+ description_markdown?: string | null;
33
+ pricing_info?: string | null;
34
+ price_cents?: number | null;
35
+ photo_attachments?: PhotoAttachment[];
36
+ package_items?: Array<{ quantity: number; service_item?: { id: number; name: string; slug: string; summary?: string | null } }>;
37
+ }
38
+
39
+ /** Package with category names and first-service description fallback (derived from services). */
40
+ export interface PackageForMenu extends PackagePublic {
41
+ category_names?: string[];
42
+ first_service_description_markdown?: string | null;
43
+ }
44
+
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
+ export interface ServiceMenuSectionProps {
52
+ title?: string;
53
+ subtitle?: string;
54
+ offers?: OfferPublic[] | null;
55
+ packages?: PackagePublic[] | null;
56
+ /** Services (used to derive service items for the third row). */
57
+ services?: Service[] | null;
58
+ websitePhotos?: WebsitePhotos | null;
59
+ companyInformation?: CompanyInformation | null;
60
+ viewAllHref?: string;
61
+ /** Label for the View All button (e.g. "View All"). */
62
+ viewAllText?: string;
63
+ /** Customer-friendly label for the third row (individual service items). Default "Treatments". */
64
+ servicesRowTitle?: string;
65
+ /** 'section' = compact header + carousel (e.g. home). 'page' = hero + grid (e.g. /service-menu). */
66
+ variant?: 'section' | 'page';
67
+ }
68
+
69
+ /** Service item with parent service info for display in the Service Menu (fallback description from service when item has none). */
70
+ export interface ServiceItemWithService extends ServiceItem {
71
+ service_name?: string;
72
+ service_summary?: string | null;
73
+ service_description_markdown?: string | null;
74
+ }
75
+
76
+ type DetailItem =
77
+ | { type: 'offer'; item: OfferForMenu }
78
+ | { type: 'package'; item: PackageForMenu }
79
+ | { type: 'service_item'; item: ServiceItemWithService };
80
+
81
+ function photoAttachmentDisplayUrl(pa: PhotoAttachment): string | undefined {
82
+ return pa.photo?.large_url || pa.photo?.medium_url;
83
+ }
84
+ function photoAttachmentAlt(pa: PhotoAttachment): string {
85
+ return pa.photo?.alt_text || pa.photo?.title || '';
86
+ }
87
+
88
+ /** Seeded shuffle: same seed => same order (SSR-safe). Different seeds => very different orders. */
89
+ function shuffleWithSeed<T>(array: T[], seed: string): T[] {
90
+ if (array.length <= 1) return array;
91
+ const arr = [...array];
92
+ // FNV-1a style hash so small seed changes (e.g. index 0 vs 1 vs 2) produce very different values
93
+ let h = 2166136261 >>> 0;
94
+ for (let i = 0; i < seed.length; i++) {
95
+ h ^= seed.charCodeAt(i);
96
+ h = (Math.imul(h, 16777619) >>> 0) >>> 0;
97
+ }
98
+ const next = (step: number) => {
99
+ h = (Math.imul(1664525, (h + step) >>> 0) + 1013904223) >>> 0;
100
+ return (h >>> 0) / 4294967296;
101
+ };
102
+ for (let i = arr.length - 1; i > 0; i--) {
103
+ const j = Math.floor(next(i) * (i + 1));
104
+ [arr[i], arr[j]] = [arr[j], arr[i]];
105
+ }
106
+ return arr;
107
+ }
108
+
109
+ const CROSSFADE_DURATION_MS = 600;
110
+
111
+ /** Returns shuffled list of { url, alt }. Card owns all cycle/transition state. */
112
+ function useCycledPhotoList(
113
+ photoAttachments: PhotoAttachment[] | undefined,
114
+ seed: string
115
+ ): Array<{ url: string; alt: string }> {
116
+ return useMemo(
117
+ () => {
118
+ const arr = Array.isArray(photoAttachments) && photoAttachments.length > 0 ? photoAttachments : [];
119
+ if (arr.length === 0) return [];
120
+ const shuffled = shuffleWithSeed(arr, seed);
121
+ return shuffled
122
+ .map((pa) => ({ url: photoAttachmentDisplayUrl(pa) ?? '', alt: photoAttachmentAlt(pa) }))
123
+ .filter((x) => x.url);
124
+ },
125
+ [photoAttachments, seed]
126
+ );
127
+ }
128
+
129
+ /** Card with image area. Cycles through photos every N ms with a simple crossfade. */
130
+ function GridCardWithImage({
131
+ photoAttachments,
132
+ fallbackId,
133
+ fallbackAlt,
134
+ title,
135
+ subtitle,
136
+ children,
137
+ onClick,
138
+ websitePhotos,
139
+ companyInformation,
140
+ cycleSeed,
141
+ }: {
142
+ photoAttachments?: PhotoAttachment[];
143
+ fallbackId: string | number;
144
+ fallbackAlt: string;
145
+ title: string;
146
+ subtitle?: string | null;
147
+ children?: React.ReactNode;
148
+ onClick: () => void;
149
+ websitePhotos?: WebsitePhotos | null;
150
+ companyInformation?: CompanyInformation | null;
151
+ cycleSeed?: string;
152
+ }) {
153
+ const seed = cycleSeed ?? String(fallbackId);
154
+ const list = useCycledPhotoList(photoAttachments, seed);
155
+ const [currentIndex, setCurrentIndex] = useState(0);
156
+ const [transitioning, setTransitioning] = useState(false);
157
+
158
+ // Per-card random interval 6–8s so cards don't all transition in sync
159
+ const intervalMs = useMemo(
160
+ () =>
161
+ CYCLE_INTERVAL_MIN_MS +
162
+ Math.floor(seedToUnit(seed) * (CYCLE_INTERVAL_MAX_MS - CYCLE_INTERVAL_MIN_MS + 1)),
163
+ [seed]
164
+ );
165
+
166
+ // Timer: every N ms, start a crossfade
167
+ useEffect(() => {
168
+ if (list.length <= 1) return;
169
+ const id = setInterval(() => setTransitioning(true), intervalMs);
170
+ return () => clearInterval(id);
171
+ }, [list.length, intervalMs]);
172
+
173
+ // When transitioning, after crossfade duration advance index and stop transitioning
174
+ useEffect(() => {
175
+ if (!transitioning || list.length <= 1) return;
176
+ const t = setTimeout(() => {
177
+ setCurrentIndex((i) => (i + 1) % list.length);
178
+ setTransitioning(false);
179
+ }, CROSSFADE_DURATION_MS);
180
+ return () => clearTimeout(t);
181
+ }, [transitioning, list.length]);
182
+
183
+ const nextIndex = list.length > 1 ? (currentIndex + 1) % list.length : 0;
184
+ const currentItem = list[currentIndex];
185
+ const displayAlt = currentItem?.alt || fallbackAlt;
186
+ const singleUrl = list[0]?.url;
187
+
188
+ return (
189
+ <button
190
+ type="button"
191
+ onClick={onClick}
192
+ 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
+ <div className="w-full h-36 overflow-hidden rounded-lg mb-3 relative">
195
+ {list.length === 0 ? (
196
+ <PhotoWithFallback
197
+ item={undefined}
198
+ fallbackId={fallbackId}
199
+ alt={fallbackAlt}
200
+ className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
201
+ websitePhotos={websitePhotos}
202
+ companyInformation={companyInformation}
203
+ />
204
+ ) : list.length === 1 && singleUrl ? (
205
+ // eslint-disable-next-line @next/next/no-img-element -- dynamic API URLs; next/image not configured for external host
206
+ <img
207
+ src={singleUrl}
208
+ alt={displayAlt}
209
+ className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
210
+ />
211
+ ) : (
212
+ <>
213
+ {/* Only animate opacity when transitioning; when settling, snap so we don't double-fade */}
214
+ <div
215
+ className={`absolute inset-0 ${transitioning ? 'transition-opacity duration-[600ms] ease-in-out' : 'transition-none'}`}
216
+ style={{ opacity: transitioning ? 0 : 1 }}
217
+ >
218
+ {/* eslint-disable-next-line @next/next/no-img-element -- dynamic API URLs */}
219
+ <img
220
+ src={list[currentIndex]?.url}
221
+ alt={list[currentIndex]?.alt ?? displayAlt}
222
+ className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
223
+ />
224
+ </div>
225
+ <div
226
+ className={`absolute inset-0 ${transitioning ? 'transition-opacity duration-[600ms] ease-in-out' : 'transition-none'}`}
227
+ style={{ opacity: transitioning ? 1 : 0 }}
228
+ >
229
+ {/* eslint-disable-next-line @next/next/no-img-element -- dynamic API URLs */}
230
+ <img
231
+ src={list[nextIndex]?.url}
232
+ alt={list[nextIndex]?.alt ?? displayAlt}
233
+ className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
234
+ />
235
+ </div>
236
+ </>
237
+ )}
238
+ </div>
239
+ {subtitle && (
240
+ <p className="text-xs font-medium text-fg-secondary uppercase tracking-wide line-clamp-1">
241
+ {subtitle}
242
+ </p>
243
+ )}
244
+ <h4 className="font-display text-base font-normal text-fg-primary mt-1 group-hover:underline line-clamp-2">
245
+ {title}
246
+ </h4>
247
+ {children}
248
+ </button>
249
+ );
250
+ }
251
+
252
+ /** Single carousel row with small cards. Title and prev/next on one line to avoid excess spacing. */
253
+ function CarouselRow<T>({
254
+ rowTitle,
255
+ items,
256
+ renderItem,
257
+ }: {
258
+ rowTitle: string;
259
+ items: T[];
260
+ renderItem: (item: T, index: number) => React.ReactNode;
261
+ }) {
262
+ if (!items?.length) return null;
263
+ return (
264
+ <div className="mb-12 last:mb-0">
265
+ <Carousel.Root opts={{ align: 'start', loop: true }}>
266
+ <div className="flex items-center justify-between gap-4 mb-2">
267
+ <h3 className="font-display text-2xl font-normal text-fg-primary md:text-3xl">
268
+ {rowTitle}
269
+ </h3>
270
+ <div className="flex gap-2 flex-shrink-0">
271
+ <Carousel.PrevTrigger className="rounded-full p-1.5 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
272
+ <svg className="w-5 h-5 text-fg-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
273
+ </Carousel.PrevTrigger>
274
+ <Carousel.NextTrigger className="rounded-full p-1.5 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
275
+ <svg className="w-5 h-5 text-fg-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
276
+ </Carousel.NextTrigger>
277
+ </div>
278
+ </div>
279
+ <Carousel.Content className="-ml-3">
280
+ {items.map((item, index) => (
281
+ <Carousel.Item key={(item as { id?: number }).id ?? index} className="pl-3 basis-[70%] sm:basis-1/2 md:basis-1/3 lg:basis-1/4">
282
+ {renderItem(item, index)}
283
+ </Carousel.Item>
284
+ ))}
285
+ </Carousel.Content>
286
+ </Carousel.Root>
287
+ </div>
288
+ );
289
+ }
290
+
291
+ /** Grid row: same section title + items in a responsive grid (top to bottom, left to right). */
292
+ function GridRow<T>({
293
+ rowTitle,
294
+ items,
295
+ renderItem,
296
+ }: {
297
+ rowTitle: string;
298
+ items: T[];
299
+ renderItem: (item: T, index: number) => React.ReactNode;
300
+ }) {
301
+ if (!items?.length) return null;
302
+ return (
303
+ <div className="mb-12 last:mb-0">
304
+ <h3 className="font-display text-2xl font-normal text-fg-primary mb-3 md:text-3xl">
305
+ {rowTitle}
306
+ </h3>
307
+ <div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
308
+ {items.map((item, index) => (
309
+ <div key={(item as { id?: number }).id ?? index} className="min-w-0 flex">
310
+ {renderItem(item, index)}
311
+ </div>
312
+ ))}
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ /** One menu block: either carousel or grid, same title + cards. */
319
+ function MenuBlock<T>({
320
+ rowTitle,
321
+ items,
322
+ renderItem,
323
+ layout,
324
+ }: {
325
+ rowTitle: string;
326
+ items: T[];
327
+ renderItem: (item: T, index: number) => React.ReactNode;
328
+ layout: 'carousel' | 'grid';
329
+ }) {
330
+ if (!items?.length) return null;
331
+ return layout === 'carousel' ? (
332
+ <CarouselRow rowTitle={rowTitle} items={items} renderItem={renderItem} />
333
+ ) : (
334
+ <GridRow rowTitle={rowTitle} items={items} renderItem={renderItem} />
335
+ );
336
+ }
337
+
338
+ function formatPriceCents(cents: number | null | undefined): string | null {
339
+ if (cents == null) return null;
340
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(cents / 100);
341
+ }
342
+
343
+ /** Section heading inside modal for consistent structure */
344
+ function ModalSection({ title, children }: { title: string; children: React.ReactNode }) {
345
+ return (
346
+ <div>
347
+ <h3 className="font-display text-sm font-semibold text-fg-primary uppercase tracking-wide mb-2">{title}</h3>
348
+ {children}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ /** Renders modal body for the selected offer, package, or service item. */
354
+ function DetailModalContent({
355
+ detail,
356
+ serviceItems = [],
357
+ }: {
358
+ detail: DetailItem;
359
+ services?: Service[];
360
+ packages?: PackageForMenu[];
361
+ serviceItems?: ServiceItemWithService[];
362
+ }) {
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
+ if (detail.type === 'package') {
438
+ const p = detail.item;
439
+ const categoryLine = p.category_names?.length ? p.category_names.join(' | ') : null;
440
+ const descriptionMarkdown = p.description_markdown || p.summary || p.first_service_description_markdown;
441
+
442
+ return (
443
+ <div className="space-y-6">
444
+ {categoryLine && (
445
+ <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{categoryLine}</p>
446
+ )}
447
+ {descriptionMarkdown && (
448
+ <ModalSection title="Package details">
449
+ <div className="prose prose-sm font-body text-fg-primary max-w-none">
450
+ <MarkdownRenderer content={descriptionMarkdown} />
451
+ </div>
452
+ </ModalSection>
453
+ )}
454
+
455
+ {p.package_items && p.package_items.length > 0 && (
456
+ <ModalSection title="What's included">
457
+ <ul className="space-y-4">
458
+ {p.package_items.map((pi, i) => {
459
+ const fullItem = pi.service_item?.id != null ? serviceItems.find((si) => si.id === pi.service_item!.id) : null;
460
+ const name = fullItem?.name ?? pi.service_item?.name ?? 'Item';
461
+ const desc = fullItem?.summary ?? fullItem?.description_markdown ?? pi.service_item?.summary;
462
+ return (
463
+ <li key={pi.service_item?.id ?? i} className="border border-secondary rounded-lg p-4 bg-secondary/20">
464
+ <p className="font-display font-medium text-fg-primary">
465
+ {pi.quantity > 1 && `${pi.quantity}× `}{name}
466
+ </p>
467
+ {desc && (
468
+ <div className="mt-2 prose prose-sm font-body text-fg-secondary max-w-none">
469
+ {typeof desc === 'string' && !desc.includes('\n') && !desc.match(/[#*\[\]]/) ? (
470
+ <p>{desc}</p>
471
+ ) : (
472
+ <MarkdownRenderer content={desc} />
473
+ )}
474
+ </div>
475
+ )}
476
+ </li>
477
+ );
478
+ })}
479
+ </ul>
480
+ </ModalSection>
481
+ )}
482
+
483
+ {p.pricing_info && (
484
+ <ModalSection title="Pricing">
485
+ <div className="rounded-lg border border-secondary bg-secondary/40 p-4">
486
+ <div className="prose prose-sm font-body text-fg-primary max-w-none">
487
+ <MarkdownRenderer content={p.pricing_info} />
488
+ </div>
489
+ </div>
490
+ </ModalSection>
491
+ )}
492
+ </div>
493
+ );
494
+ }
495
+
496
+ const si = detail.item;
497
+ const priceStr = formatPriceCents(si.price_cents);
498
+ const summary = si.summary || si.service_summary;
499
+ const descriptionMarkdown = si.description_markdown || si.service_description_markdown;
500
+
501
+ return (
502
+ <div className="space-y-6">
503
+ {si.service_name && (
504
+ <p className="text-sm font-medium text-fg-secondary uppercase tracking-wide">{si.service_name}</p>
505
+ )}
506
+
507
+ {priceStr && (
508
+ <ModalSection title="Price">
509
+ <p className="font-display text-lg font-normal text-fg-primary">{priceStr}</p>
510
+ {si.duration_minutes != null && si.duration_minutes > 0 && (
511
+ <p className="font-body text-sm text-fg-secondary mt-1">Duration: {si.duration_minutes} min</p>
512
+ )}
513
+ </ModalSection>
514
+ )}
515
+
516
+ {summary && (
517
+ <ModalSection title="Overview">
518
+ <p className="font-body text-fg-primary">{summary}</p>
519
+ </ModalSection>
520
+ )}
521
+
522
+ {descriptionMarkdown && (
523
+ <ModalSection title="Full description">
524
+ <div className="prose prose-sm font-body text-fg-primary max-w-none">
525
+ <MarkdownRenderer content={descriptionMarkdown} />
526
+ </div>
527
+ </ModalSection>
528
+ )}
529
+
530
+ {si.pricing_info && (
531
+ <ModalSection title="Pricing details">
532
+ <div className="rounded-lg border border-secondary bg-secondary/40 p-4">
533
+ <div className="prose prose-sm font-body text-fg-primary max-w-none">
534
+ <MarkdownRenderer content={si.pricing_info} />
535
+ </div>
536
+ </div>
537
+ </ModalSection>
538
+ )}
539
+ </div>
540
+ );
541
+ }
542
+
543
+ /**
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.
547
+ */
548
+ export function ServiceMenuSection({
549
+ title = 'Service Menu',
550
+ subtitle,
551
+ offers = null,
552
+ packages = null,
553
+ services = null,
554
+ websitePhotos,
555
+ companyInformation,
556
+ viewAllHref,
557
+ viewAllText = 'View All',
558
+ servicesRowTitle = 'Treatments',
559
+ variant = 'section',
560
+ }: ServiceMenuSectionProps) {
561
+ const offerList = React.useMemo(
562
+ () => (Array.isArray(offers) ? offers.filter((o) => !o.expired) : []),
563
+ [offers]
564
+ );
565
+ const packageList = React.useMemo(
566
+ () => (Array.isArray(packages) ? packages : []),
567
+ [packages]
568
+ );
569
+ const serviceList = React.useMemo(
570
+ () => (Array.isArray(services) ? services : []),
571
+ [services]
572
+ );
573
+
574
+ const serviceItemIdToService = React.useMemo(() => {
575
+ const m = new Map<number, Service>();
576
+ serviceList.forEach((s) => (s.service_items || []).forEach((si) => m.set(si.id, s)));
577
+ return m;
578
+ }, [serviceList]);
579
+
580
+ const packagesForMenu: PackageForMenu[] = React.useMemo(
581
+ () =>
582
+ packageList.map((pkg) => {
583
+ const category_names = [
584
+ ...new Set(
585
+ (pkg.package_items || [])
586
+ .map((pi) => serviceItemIdToService.get(pi.service_item?.id ?? 0)?.name)
587
+ .filter(Boolean) as string[]
588
+ ),
589
+ ];
590
+ const firstPi = pkg.package_items?.[0];
591
+ const firstService = firstPi ? serviceItemIdToService.get(firstPi.service_item?.id ?? 0) : undefined;
592
+ const first_service_description_markdown =
593
+ firstService?.description_markdown || firstService?.summary || null;
594
+ return { ...pkg, category_names, first_service_description_markdown };
595
+ }),
596
+ [packageList, serviceItemIdToService]
597
+ );
598
+
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
+ const serviceItemsForMenu: ServiceItemWithService[] = serviceList.flatMap((s) =>
617
+ (s.service_items || []).map((si) => ({
618
+ ...si,
619
+ service_name: s.name,
620
+ service_summary: s.summary,
621
+ service_description_markdown: s.description_markdown,
622
+ }))
623
+ );
624
+ const hasAny = offersForMenu.length > 0 || packagesForMenu.length > 0 || serviceItemsForMenu.length > 0;
625
+
626
+ const CAROUSEL_MIN = 3;
627
+ // Page variant: always grid (full list, no carousel). Section variant: carousel when >=3 items else grid.
628
+ const isPage = variant === 'page';
629
+ const offersLayout: 'carousel' | 'grid' = isPage ? 'grid' : (offersForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
630
+ const packagesLayout: 'carousel' | 'grid' = isPage ? 'grid' : (packagesForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
631
+ const serviceItemsLayout: 'carousel' | 'grid' = isPage ? 'grid' : (serviceItemsForMenu.length >= CAROUSEL_MIN ? 'carousel' : 'grid');
632
+
633
+ const [detailItem, setDetailItem] = useState<DetailItem | null>(null);
634
+ const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
635
+
636
+ useEffect(() => {
637
+ let el = document.getElementById(SERVICE_MENU_MODAL_ROOT_ID);
638
+ if (!el) {
639
+ el = document.createElement('div');
640
+ el.id = SERVICE_MENU_MODAL_ROOT_ID;
641
+ document.body.appendChild(el);
642
+ }
643
+ // Portal root: set DOM ref once on mount (standard createPortal pattern)
644
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: portal mount node
645
+ setPortalTarget(el);
646
+ return () => { el?.remove(); };
647
+ }, []);
648
+
649
+ const closeModal = useCallback(() => setDetailItem(null), []);
650
+
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
+ const renderPackageCard = useCallback(
680
+ (pkg: PackageForMenu, index: number) => {
681
+ const categorySubtitle = pkg.category_names?.length ? pkg.category_names.join(' | ') : null;
682
+ const cardDesc = pkg.description_markdown || pkg.summary || pkg.first_service_description_markdown;
683
+ const plainDesc = cardDesc ? cardDesc.replace(/[#*`\[\]]/g, '').replace(/\n+/g, ' ').trim() : '';
684
+ const descText = plainDesc.length > 120 ? plainDesc.slice(0, 120) + '…' : plainDesc;
685
+ return (
686
+ <GridCardWithImage
687
+ photoAttachments={pkg.photo_attachments}
688
+ fallbackId={`pkg-${pkg.id}`}
689
+ fallbackAlt={pkg.name}
690
+ title={pkg.name}
691
+ subtitle={categorySubtitle}
692
+ onClick={() => setDetailItem({ type: 'package', item: pkg })}
693
+ websitePhotos={websitePhotos}
694
+ companyInformation={companyInformation}
695
+ cycleSeed={`pkg-${pkg.id}-${index}`}
696
+ >
697
+ {descText && (
698
+ <p className="font-body text-sm text-tertiary mt-1 line-clamp-2">{descText}</p>
699
+ )}
700
+ </GridCardWithImage>
701
+ );
702
+ },
703
+ [websitePhotos, companyInformation]
704
+ );
705
+
706
+ const renderServiceItemCard = useCallback(
707
+ (si: ServiceItemWithService, index: number) => (
708
+ <GridCardWithImage
709
+ photoAttachments={si.photo_attachments}
710
+ fallbackId={`service-item-${si.id}`}
711
+ fallbackAlt={si.photo_attachments?.[0]?.photo?.title || si.name}
712
+ title={si.name}
713
+ subtitle={si.service_name ?? null}
714
+ onClick={() => setDetailItem({ type: 'service_item', item: si })}
715
+ websitePhotos={websitePhotos}
716
+ companyInformation={companyInformation}
717
+ cycleSeed={`service-item-${si.id}-${index}`}
718
+ >
719
+ {formatPriceCents(si.price_cents) && (
720
+ <p className="font-body text-sm font-medium text-fg-primary mt-1">
721
+ {formatPriceCents(si.price_cents)}
722
+ </p>
723
+ )}
724
+ {(() => {
725
+ const desc = si.summary || si.description_markdown || si.service_summary || si.service_description_markdown;
726
+ if (!desc) return null;
727
+ const plain = desc.replace(/[#*`\[\]]/g, '').replace(/\n+/g, ' ').trim();
728
+ const text = plain.length > 120 ? plain.slice(0, 120) + '…' : plain;
729
+ return (
730
+ <p className="font-body text-sm text-tertiary mt-1 line-clamp-2">
731
+ {text}
732
+ </p>
733
+ );
734
+ })()}
735
+ </GridCardWithImage>
736
+ ),
737
+ [websitePhotos, companyInformation]
738
+ );
739
+
740
+ if (!hasAny) return null;
741
+
742
+ const modalTitle = detailItem ? detailItem.item.name : '';
743
+
744
+ return (
745
+ <section className={variant === 'page' ? 'py-12 md:py-20' : 'py-12 md:py-16'}>
746
+ <div className="mx-auto max-w-container px-4 md:px-8">
747
+ {/* Section variant: centered title/subtitle in-section. Page variant: hero is provided by the page (GenericTextHero). */}
748
+ {variant === 'section' && (
749
+ <div className="mx-auto max-w-3xl text-center mb-12 md:mb-16">
750
+ {title && (
751
+ <h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
752
+ {title}
753
+ </h2>
754
+ )}
755
+ {subtitle && (
756
+ <p className="mt-4 font-display text-lg leading-relaxed text-tertiary md:text-xl max-w-3xl mx-auto">
757
+ {subtitle}
758
+ </p>
759
+ )}
760
+ </div>
761
+ )}
762
+
763
+ <MenuBlock
764
+ rowTitle="Offers"
765
+ items={offersForMenu}
766
+ layout={offersLayout}
767
+ renderItem={renderOfferCard}
768
+ />
769
+
770
+ <MenuBlock
771
+ rowTitle="Packages"
772
+ items={packagesForMenu}
773
+ layout={packagesLayout}
774
+ renderItem={renderPackageCard}
775
+ />
776
+
777
+ <MenuBlock
778
+ rowTitle={servicesRowTitle}
779
+ items={serviceItemsForMenu}
780
+ layout={serviceItemsLayout}
781
+ renderItem={renderServiceItemCard}
782
+ />
783
+
784
+ {variant === 'section' && viewAllHref && viewAllText && (
785
+ <div className="mt-12 text-center">
786
+ <Button href={viewAllHref} color="primary" size="md">
787
+ {viewAllText}
788
+ </Button>
789
+ </div>
790
+ )}
791
+ </div>
792
+
793
+ {portalTarget &&
794
+ detailItem &&
795
+ createPortal(
796
+ <Modal
797
+ isOpen={true}
798
+ onClose={closeModal}
799
+ title={modalTitle}
800
+ maxWidth="2xl"
801
+ >
802
+ <DetailModalContent
803
+ detail={detailItem}
804
+ services={serviceList}
805
+ packages={packagesForMenu}
806
+ serviceItems={serviceItemsForMenu}
807
+ />
808
+ </Modal>,
809
+ portalTarget
810
+ )}
811
+ </section>
812
+ );
813
+ }