keystone-design-bootstrap 1.0.57 → 1.0.59
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-vWzW8yFb.d.ts +50 -0
- package/dist/contexts/index.d.ts +13 -0
- package/dist/design_system/elements/index.d.ts +383 -0
- package/dist/design_system/logo/keystone-logo.d.ts +6 -0
- package/dist/design_system/sections/index.d.ts +232 -0
- package/dist/design_system/sections/index.js +25 -37
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.d.ts +69 -0
- package/dist/index.js +25 -37
- package/dist/index.js.map +1 -1
- package/dist/lib/component-registry.d.ts +13 -0
- package/dist/lib/hooks/index.d.ts +83 -0
- package/dist/lib/server-api.d.ts +44 -0
- package/dist/package-CB1tENyG.d.ts +148 -0
- package/dist/photos-CmBdWiuZ.d.ts +27 -0
- package/dist/themes/index.d.ts +16 -0
- package/dist/types/index.d.ts +312 -0
- package/dist/utils/cx.d.ts +15 -0
- package/dist/utils/gradient-placeholder.d.ts +8 -0
- package/dist/utils/is-react-component.d.ts +21 -0
- package/dist/utils/markdown-toc.d.ts +14 -0
- package/dist/utils/phone-helpers.d.ts +24 -0
- package/dist/utils/photo-helpers.d.ts +38 -0
- package/dist/website-photos-Cl1YqAno.d.ts +21 -0
- package/package.json +1 -1
- package/src/design_system/portal/PortalPage.tsx +107 -91
- package/src/design_system/portal/PortalTabTracker.tsx +24 -0
- package/src/design_system/sections/contact-section-form.aman.tsx +2 -2
- package/src/design_system/sections/contact-section-form.balance.tsx +2 -2
- package/src/design_system/sections/contact-section-form.barelux.tsx +2 -2
- package/src/design_system/sections/contact-section-form.tsx +2 -2
- package/src/design_system/sections/header-navigation.aman.tsx +1 -4
- package/src/design_system/sections/header-navigation.balance.tsx +1 -4
- package/src/design_system/sections/header-navigation.barelux.tsx +1 -4
- package/src/design_system/sections/header-navigation.tsx +1 -8
- package/src/index.ts +1 -1
- package/src/lib/cta-urls.ts +15 -38
- package/src/next/layouts/root-layout.tsx +6 -7
- package/src/next/routes/consumer-auth.ts +2 -4
- package/src/tracking/MetaPixelTracker.tsx +17 -12
- package/src/tracking/firePixelEvent.ts +26 -0
- package/src/tracking/index.ts +2 -6
- package/src/types/api/company-information.ts +2 -0
- package/src/tracking/BookingCtaTracker.tsx +0 -32
- package/src/tracking/ViewContentTracker.tsx +0 -21
- package/src/tracking/trackInitiateCheckout.ts +0 -16
- package/src/tracking/trackMetaLead.ts +0 -14
- package/src/tracking/trackViewContent.ts +0 -19
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for extracting table of contents from markdown
|
|
3
|
+
*/
|
|
4
|
+
interface TableOfContentsItem {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
level: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Extract headings from markdown content and generate table of contents
|
|
11
|
+
*/
|
|
12
|
+
declare function extractTableOfContents(markdown: string): TableOfContentsItem[];
|
|
13
|
+
|
|
14
|
+
export { type TableOfContentsItem, extractTableOfContents };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List of countries with their respective country code, flag, phone code, and phone mask.
|
|
3
|
+
*/
|
|
4
|
+
declare const countries: ({
|
|
5
|
+
name: string;
|
|
6
|
+
code: string;
|
|
7
|
+
flag: string;
|
|
8
|
+
phoneCode: string;
|
|
9
|
+
phoneMask: string;
|
|
10
|
+
} | {
|
|
11
|
+
name: string;
|
|
12
|
+
code: string;
|
|
13
|
+
flag: string;
|
|
14
|
+
phoneCode: string;
|
|
15
|
+
phoneMask?: undefined;
|
|
16
|
+
})[];
|
|
17
|
+
|
|
18
|
+
type Country = (typeof countries)[0];
|
|
19
|
+
/** Get national-format mask from country by stripping the country code prefix (e.g. "+1 (###) ###-####" → "(###) ###-####"). */
|
|
20
|
+
declare function getNationalMask(country: Country | undefined): string;
|
|
21
|
+
/** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */
|
|
22
|
+
declare function formatDigitsToMask(digits: string, mask: string): string;
|
|
23
|
+
|
|
24
|
+
export { formatDigitsToMask, getNationalMask };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { P as PhotoAttachment } from '../photos-CmBdWiuZ.js';
|
|
2
|
+
import { W as WebsitePhotos } from '../website-photos-Cl1YqAno.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper functions for extracting photo URLs from photo associations
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.
|
|
10
|
+
*/
|
|
11
|
+
declare function isVideoUrl(url: string | null | undefined): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Get the best available photo URL from a photos array
|
|
14
|
+
* Priority: featured photo > first photo > fallback
|
|
15
|
+
*/
|
|
16
|
+
declare function getPhotoUrl(photos?: PhotoAttachment[]): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Get avatar URL for team members or authors.
|
|
19
|
+
* Returns a URL only when there is a real photo in attachments; otherwise null.
|
|
20
|
+
* Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.
|
|
21
|
+
*/
|
|
22
|
+
/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */
|
|
23
|
+
declare function getAvatarUrl(photos?: PhotoAttachment[], _fallbackId?: number | string, _name?: string): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Get featured image URL for blog posts
|
|
26
|
+
*/
|
|
27
|
+
declare function getFeaturedImageUrl(photos?: PhotoAttachment[]): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Get logo URL from website_photos API (which aggregates from account_photos)
|
|
30
|
+
*
|
|
31
|
+
* The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',
|
|
32
|
+
* with industry fallback. This is the primary and only source for logos.
|
|
33
|
+
*
|
|
34
|
+
* Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)
|
|
35
|
+
*/
|
|
36
|
+
declare function getLogoUrl(websitePhotos?: WebsitePhotos | null): string | undefined;
|
|
37
|
+
|
|
38
|
+
export { PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl, isVideoUrl };
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export type { WebsitePhotos as W, WebsitePhoto as a, WebsitePhotosResponse as b };
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { LogoutButton } from './LogoutButton';
|
|
|
6
6
|
import { LoginModalController } from './LoginModalController';
|
|
7
7
|
import { MessageComposer } from './MessageComposer';
|
|
8
8
|
import { RowThumbnail } from './RowThumbnail';
|
|
9
|
+
import { PortalTabTracker } from './PortalTabTracker';
|
|
9
10
|
import {
|
|
10
11
|
CONSUMER_TOKEN_COOKIE,
|
|
11
12
|
fetchConsumerMe,
|
|
@@ -264,38 +265,41 @@ function ServicesPanel({
|
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
return (
|
|
267
|
-
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
<
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
268
|
+
<>
|
|
269
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} />
|
|
270
|
+
<div className="divide-y divide-tertiary rounded-xl border border-secondary bg-primary overflow-hidden">
|
|
271
|
+
{activeServices.map((service) => (
|
|
272
|
+
<details key={service.id} className="group">
|
|
273
|
+
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-4 hover:bg-secondary transition-colors">
|
|
274
|
+
<div>
|
|
275
|
+
<span className="font-medium text-primary">{service.name}</span>
|
|
276
|
+
{service.summary && (
|
|
277
|
+
<p className="mt-0.5 text-sm text-tertiary">{service.summary}</p>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
<div className="ml-4 flex items-center gap-2 shrink-0">
|
|
281
|
+
<span className="text-xs text-quaternary">{service.service_items?.length ?? 0} items</span>
|
|
282
|
+
<svg className="size-4 text-quaternary transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
283
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
284
|
+
</svg>
|
|
285
|
+
</div>
|
|
286
|
+
</summary>
|
|
287
|
+
{service.service_items && service.service_items.length > 0 && (
|
|
288
|
+
<div className="border-t border-tertiary bg-secondary divide-y divide-tertiary">
|
|
289
|
+
{service.service_items.map((item) => (
|
|
290
|
+
<ServiceItemRow
|
|
291
|
+
key={item.id}
|
|
292
|
+
item={item}
|
|
293
|
+
isLoggedIn={isLoggedIn}
|
|
294
|
+
specialsHref={specialsHref}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</details>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
</>
|
|
299
303
|
);
|
|
300
304
|
}
|
|
301
305
|
|
|
@@ -315,11 +319,13 @@ function PackagesPanel({
|
|
|
315
319
|
if (packages.length === 0) return <EmptyState message="No packages available yet." />;
|
|
316
320
|
|
|
317
321
|
return (
|
|
318
|
-
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
322
|
+
<>
|
|
323
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Packages', contentCategory: 'Packages' }} />
|
|
324
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
325
|
+
{packages.map((pkg) => {
|
|
326
|
+
const activeOffers = (pkg.offers ?? []).filter((o) => o.active !== false && !o.expired);
|
|
327
|
+
return (
|
|
328
|
+
<div key={pkg.id} className="group rounded-xl border border-secondary bg-primary p-4 flex flex-col gap-3">
|
|
323
329
|
<div className="flex items-start gap-3">
|
|
324
330
|
<RowThumbnail
|
|
325
331
|
photoAttachments={pkg.photo_attachments}
|
|
@@ -382,10 +388,11 @@ function PackagesPanel({
|
|
|
382
388
|
)}
|
|
383
389
|
</div>
|
|
384
390
|
)}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
})}
|
|
394
|
+
</div>
|
|
395
|
+
</>
|
|
389
396
|
);
|
|
390
397
|
}
|
|
391
398
|
|
|
@@ -397,31 +404,34 @@ function SpecialsPanel({ specials }: { specials: SpecialItem[] }) {
|
|
|
397
404
|
}
|
|
398
405
|
|
|
399
406
|
return (
|
|
400
|
-
|
|
401
|
-
{
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<p className="
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
<p className="mt-0.5 text-xs text-quaternary">
|
|
418
|
-
Expires {new Date(special.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
407
|
+
<>
|
|
408
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} />
|
|
409
|
+
<div className="space-y-3">
|
|
410
|
+
{specials.map((special) => (
|
|
411
|
+
<div key={special.id} className="group flex items-start gap-3 rounded-xl border border-secondary bg-primary px-4 py-4">
|
|
412
|
+
<RowThumbnail
|
|
413
|
+
photoAttachments={special.photoAttachments}
|
|
414
|
+
seed={`special-${special.id}`}
|
|
415
|
+
alt={special.parentName}
|
|
416
|
+
/>
|
|
417
|
+
<div className="flex-1 min-w-0">
|
|
418
|
+
<p className="font-semibold text-primary">{special.name}</p>
|
|
419
|
+
{special.value_terms && (
|
|
420
|
+
<p className="mt-0.5 text-sm text-secondary">{special.value_terms}</p>
|
|
421
|
+
)}
|
|
422
|
+
<p className="mt-1 text-xs text-quaternary">
|
|
423
|
+
{special.parentType === 'service' ? 'Service' : 'Package'}: {special.parentName}
|
|
419
424
|
</p>
|
|
420
|
-
|
|
425
|
+
{special.expires_at && (
|
|
426
|
+
<p className="mt-0.5 text-xs text-quaternary">
|
|
427
|
+
Expires {new Date(special.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
428
|
+
</p>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
421
431
|
</div>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
432
|
+
))}
|
|
433
|
+
</div>
|
|
434
|
+
</>
|
|
425
435
|
);
|
|
426
436
|
}
|
|
427
437
|
|
|
@@ -510,38 +520,44 @@ function BookPanel({
|
|
|
510
520
|
|
|
511
521
|
if (bookingAllowsIframe) {
|
|
512
522
|
return (
|
|
513
|
-
|
|
514
|
-
<
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
523
|
+
<>
|
|
524
|
+
<PortalTabTracker event="InitiateCheckout" />
|
|
525
|
+
<div className="rounded-xl border border-secondary overflow-hidden" style={{ height: '70vh' }}>
|
|
526
|
+
<iframe
|
|
527
|
+
src={bookingHref}
|
|
528
|
+
className="w-full h-full"
|
|
529
|
+
title="Book appointment"
|
|
530
|
+
allow="payment"
|
|
531
|
+
/>
|
|
532
|
+
</div>
|
|
533
|
+
</>
|
|
521
534
|
);
|
|
522
535
|
}
|
|
523
536
|
|
|
524
537
|
return (
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
538
|
+
<>
|
|
539
|
+
<PortalTabTracker event="InitiateCheckout" />
|
|
540
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
541
|
+
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
542
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
543
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 9v7.5" />
|
|
544
|
+
</svg>
|
|
545
|
+
</div>
|
|
546
|
+
<p className="text-sm font-medium text-primary">Ready to book?</p>
|
|
547
|
+
<p className="mt-1 text-sm text-tertiary">You'll be taken to our booking system.</p>
|
|
548
|
+
<a
|
|
549
|
+
href={bookingHref}
|
|
550
|
+
target="_blank"
|
|
551
|
+
rel="noopener noreferrer"
|
|
552
|
+
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-gray-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
|
|
553
|
+
>
|
|
554
|
+
{bookingLabel}
|
|
555
|
+
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
556
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
557
|
+
</svg>
|
|
558
|
+
</a>
|
|
530
559
|
</div>
|
|
531
|
-
|
|
532
|
-
<p className="mt-1 text-sm text-tertiary">You'll be taken to our booking system.</p>
|
|
533
|
-
<a
|
|
534
|
-
href={bookingHref}
|
|
535
|
-
target="_blank"
|
|
536
|
-
rel="noopener noreferrer"
|
|
537
|
-
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-gray-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
|
|
538
|
-
>
|
|
539
|
-
{bookingLabel}
|
|
540
|
-
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
541
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
542
|
-
</svg>
|
|
543
|
-
</a>
|
|
544
|
-
</div>
|
|
560
|
+
</>
|
|
545
561
|
);
|
|
546
562
|
}
|
|
547
563
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
5
|
+
import type { PixelEvent, PixelEventParams } from '../../tracking/firePixelEvent';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
event: PixelEvent;
|
|
9
|
+
params?: PixelEventParams;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fires a pixel event once when a portal tab mounts.
|
|
14
|
+
* Placed at the root of each tab panel so it fires on both direct navigation
|
|
15
|
+
* and post-login redirect to that tab.
|
|
16
|
+
*/
|
|
17
|
+
export function PortalTabTracker({ event, params }: Props) {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
firePixelEvent(event, params);
|
|
20
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import {
|
|
6
|
+
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
-
|
|
64
|
+
firePixelEvent('Lead');
|
|
65
65
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
66
|
} else {
|
|
67
67
|
setSubmitStatus('error');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import {
|
|
6
|
+
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
-
|
|
64
|
+
firePixelEvent('Lead');
|
|
65
65
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
66
|
} else {
|
|
67
67
|
setSubmitStatus('error');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import {
|
|
6
|
+
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
-
|
|
64
|
+
firePixelEvent('Lead');
|
|
65
65
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
66
|
} else {
|
|
67
67
|
setSubmitStatus('error');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import {
|
|
6
|
+
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -69,7 +69,7 @@ export const ContactSectionForm = ({
|
|
|
69
69
|
setStatusMessage(result.message || successMessage);
|
|
70
70
|
formRef.current?.reset();
|
|
71
71
|
onSuccess?.();
|
|
72
|
-
|
|
72
|
+
firePixelEvent('Lead');
|
|
73
73
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
74
74
|
} else {
|
|
75
75
|
setSubmitStatus('error');
|
|
@@ -56,10 +56,7 @@ export function HeaderNavigation({
|
|
|
56
56
|
|
|
57
57
|
// Use navigation from config or override
|
|
58
58
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
59
|
-
const ctaUrls = resolveCtaUrls(companyInformation
|
|
60
|
-
primaryHrefOverride: props?.primaryHrefOverride,
|
|
61
|
-
secondaryHrefOverride: props?.secondaryHrefOverride,
|
|
62
|
-
});
|
|
59
|
+
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
63
60
|
|
|
64
61
|
// Cancel any pending close timeout
|
|
65
62
|
const cancelCloseTimeout = useCallback(() => {
|
|
@@ -35,10 +35,7 @@ export function HeaderNavigation({
|
|
|
35
35
|
const companyName = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
|
|
36
36
|
|
|
37
37
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
38
|
-
const ctaUrls = resolveCtaUrls(companyInformation
|
|
39
|
-
primaryHrefOverride: props?.primaryHrefOverride,
|
|
40
|
-
secondaryHrefOverride: props?.secondaryHrefOverride,
|
|
41
|
-
});
|
|
38
|
+
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
42
39
|
|
|
43
40
|
const cancelCloseTimeout = useCallback(() => {
|
|
44
41
|
if (closeTimeoutRef.current) {
|
|
@@ -41,10 +41,7 @@ export function HeaderNavigation({
|
|
|
41
41
|
|
|
42
42
|
// Use navigation from config or override
|
|
43
43
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
44
|
-
const ctaUrls = resolveCtaUrls(companyInformation
|
|
45
|
-
primaryHrefOverride: props?.primaryHrefOverride,
|
|
46
|
-
secondaryHrefOverride: props?.secondaryHrefOverride,
|
|
47
|
-
});
|
|
44
|
+
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
48
45
|
|
|
49
46
|
// Cancel any pending close timeout
|
|
50
47
|
const cancelCloseTimeout = useCallback(() => {
|
|
@@ -27,10 +27,6 @@ export interface HeaderProps {
|
|
|
27
27
|
secondary_href?: string;
|
|
28
28
|
secondary_target?: '_blank' | '_self';
|
|
29
29
|
};
|
|
30
|
-
/** When set, overrides the default primary CTA URL (booking or /contact). */
|
|
31
|
-
primaryHrefOverride?: string | null;
|
|
32
|
-
/** When set, overrides the default secondary CTA URL (/contact). */
|
|
33
|
-
secondaryHrefOverride?: string | null;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
export interface HeaderComponentProps {
|
|
@@ -102,10 +98,7 @@ export function HeaderNavigation({
|
|
|
102
98
|
const logoImage = logoImageOverride || getLogoUrl(websitePhotos) || props?.logo?.image;
|
|
103
99
|
const logoText = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
|
|
104
100
|
const cta_button = props?.cta_button;
|
|
105
|
-
const ctaUrls = resolveCtaUrls(companyInformation
|
|
106
|
-
primaryHrefOverride: props?.primaryHrefOverride,
|
|
107
|
-
secondaryHrefOverride: props?.secondaryHrefOverride,
|
|
108
|
-
});
|
|
101
|
+
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
109
102
|
|
|
110
103
|
const logo = {
|
|
111
104
|
text: logoText || '',
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,6 @@ export * from './design_system/sections';
|
|
|
7
7
|
export * from './design_system/elements';
|
|
8
8
|
export * from './lib/actions';
|
|
9
9
|
export { resolveCtaUrls, isExternalCtaUrl } from './lib/cta-urls';
|
|
10
|
-
export type { ResolvedCtaUrls
|
|
10
|
+
export type { ResolvedCtaUrls } from './lib/cta-urls';
|
|
11
11
|
export * from './contexts';
|
|
12
12
|
export { ChatWidget } from './design_system/components/ChatWidget';
|
package/src/lib/cta-urls.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Resolves the primary and secondary CTA hrefs used across the site.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order for primaryHref:
|
|
5
|
+
* 1. portal_url — set by backend when account has consumer portal enabled
|
|
6
|
+
* 2. external_management_url — booking platform URL from the API
|
|
7
|
+
* 3. /contact — final fallback
|
|
8
|
+
*
|
|
9
|
+
* No per-site configuration needed. Enable the portal in the admin console and
|
|
10
|
+
* every CTA across the site automatically routes to the portal.
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
13
|
import type { CompanyInformation } from '../types/api/company-information';
|
|
@@ -13,49 +19,20 @@ export function isExternalCtaUrl(href: string): boolean {
|
|
|
13
19
|
return href.startsWith('http://') || href.startsWith('https://');
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export interface CtaUrlOverrides {
|
|
17
|
-
primaryHrefOverride?: string | null;
|
|
18
|
-
secondaryHrefOverride?: string | null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface CtaButtonLike {
|
|
22
|
-
href?: string | null;
|
|
23
|
-
secondary_href?: string | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
export interface ResolvedCtaUrls {
|
|
27
23
|
primaryHref: string;
|
|
28
24
|
secondaryHref: string;
|
|
29
25
|
hasSecondary: boolean;
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
/**
|
|
33
|
-
* Resolve primary and secondary CTA hrefs from company info and optional overrides.
|
|
34
|
-
* - primaryHref = external_management_url (if set) else /contact
|
|
35
|
-
* - secondaryHref = /contact
|
|
36
|
-
* Overrides win when provided.
|
|
37
|
-
*/
|
|
38
28
|
export function resolveCtaUrls(
|
|
39
|
-
companyInformation?: CompanyInformation | null
|
|
40
|
-
_ctaButton?: CtaButtonLike | null,
|
|
41
|
-
overrides?: CtaUrlOverrides | null
|
|
29
|
+
companyInformation?: CompanyInformation | null
|
|
42
30
|
): ResolvedCtaUrls {
|
|
31
|
+
const portalUrl = companyInformation?.portal_url?.trim() || null;
|
|
43
32
|
const externalUrl = companyInformation?.external_management_url?.trim() || null;
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const primaryHref
|
|
47
|
-
overrides?.primaryHrefOverride !== undefined && overrides.primaryHrefOverride !== null
|
|
48
|
-
? overrides.primaryHrefOverride
|
|
49
|
-
: (externalUrl || CONTACT_PATH);
|
|
50
|
-
|
|
51
|
-
const secondaryHref =
|
|
52
|
-
overrides?.secondaryHrefOverride !== undefined && overrides.secondaryHrefOverride !== null
|
|
53
|
-
? overrides.secondaryHrefOverride
|
|
54
|
-
: CONTACT_PATH;
|
|
33
|
+
const primaryHref = portalUrl ?? externalUrl ?? CONTACT_PATH;
|
|
34
|
+
const secondaryHref = CONTACT_PATH;
|
|
35
|
+
const hasSecondary = primaryHref !== CONTACT_PATH;
|
|
55
36
|
|
|
56
|
-
return {
|
|
57
|
-
primaryHref,
|
|
58
|
-
secondaryHref,
|
|
59
|
-
hasSecondary,
|
|
60
|
-
};
|
|
37
|
+
return { primaryHref, secondaryHref, hasSecondary };
|
|
61
38
|
}
|
|
@@ -24,10 +24,10 @@ export type KeystoneRootLayoutHeaderOverrides = {
|
|
|
24
24
|
logoHref?: string;
|
|
25
25
|
/** Overrides the title/company name shown in the center of the nav bar (otherwise from business_info) */
|
|
26
26
|
logoText?: string;
|
|
27
|
+
/** Overrides the primary CTA button label (default: "Contact Us") */
|
|
27
28
|
ctaLabel?: string;
|
|
28
|
-
|
|
29
|
+
/** Overrides the secondary CTA button label (default: "Book Now" when a booking/portal URL is configured) */
|
|
29
30
|
secondaryLabel?: string;
|
|
30
|
-
secondaryHref?: string;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
export type KeystoneRootLayoutOptions = {
|
|
@@ -137,7 +137,9 @@ export async function KeystoneRootLayout(props: {
|
|
|
137
137
|
const theme = config.site.theme;
|
|
138
138
|
|
|
139
139
|
const ci = companyInformation as CompanyInformation | null;
|
|
140
|
-
const externalManagementUrl = ci?.external_management_url ||
|
|
140
|
+
const externalManagementUrl = ci?.external_management_url?.trim() || null;
|
|
141
|
+
const portalUrl = ci?.portal_url?.trim() || null;
|
|
142
|
+
const bookingHref = portalUrl ?? externalManagementUrl ?? null;
|
|
141
143
|
const chatEnabled = Boolean(ci?.chat_enabled);
|
|
142
144
|
|
|
143
145
|
const headerOverrides = options?.headerOverrides;
|
|
@@ -148,10 +150,7 @@ export async function KeystoneRootLayout(props: {
|
|
|
148
150
|
},
|
|
149
151
|
cta_button: {
|
|
150
152
|
label: headerOverrides?.ctaLabel || 'Contact Us',
|
|
151
|
-
|
|
152
|
-
secondary_label:
|
|
153
|
-
headerOverrides?.secondaryLabel ?? (externalManagementUrl ? 'Book Now' : undefined),
|
|
154
|
-
secondary_href: headerOverrides?.secondaryHref ?? externalManagementUrl,
|
|
153
|
+
secondary_label: headerOverrides?.secondaryLabel ?? (bookingHref ? 'Book Now' : undefined),
|
|
155
154
|
},
|
|
156
155
|
};
|
|
157
156
|
|