keystone-design-bootstrap 1.0.58 → 1.0.60
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/LoginForm.tsx +11 -11
- package/src/design_system/portal/MessageComposer.tsx +2 -2
- package/src/design_system/portal/PortalPage.tsx +121 -105
- package/src/design_system/portal/PortalTabTracker.tsx +24 -0
- package/src/design_system/portal/RowThumbnail.tsx +3 -3
- 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/styles/style-overrides.aman.css +6 -0
- package/src/styles/style-overrides.barelux.css +6 -0
- package/src/styles/theme.css +6 -0
- 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
|
@@ -13,7 +13,7 @@ interface LoginFormProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const inputClass =
|
|
16
|
-
'block w-full rounded border border-
|
|
16
|
+
'block w-full rounded-input border border-primary bg-primary px-3 py-2.5 text-sm text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors';
|
|
17
17
|
const labelClass = 'block text-sm text-secondary mb-1';
|
|
18
18
|
|
|
19
19
|
function isValidEmail(value: string): boolean {
|
|
@@ -185,7 +185,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
185
185
|
</div>
|
|
186
186
|
|
|
187
187
|
{error && (
|
|
188
|
-
<div className="mb-4 rounded border border-
|
|
188
|
+
<div className="mb-4 rounded-input border border-error bg-error-primary px-3 py-2 text-sm text-error-primary">
|
|
189
189
|
{error}
|
|
190
190
|
</div>
|
|
191
191
|
)}
|
|
@@ -206,17 +206,17 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
206
206
|
/>
|
|
207
207
|
</div>
|
|
208
208
|
<div className="flex items-center gap-3">
|
|
209
|
-
<hr className="flex-1 border-
|
|
210
|
-
<span className="text-xs text-
|
|
211
|
-
<hr className="flex-1 border-
|
|
209
|
+
<hr className="flex-1 border-secondary" />
|
|
210
|
+
<span className="text-xs text-quaternary">or</span>
|
|
211
|
+
<hr className="flex-1 border-secondary" />
|
|
212
212
|
</div>
|
|
213
213
|
<div>
|
|
214
214
|
<label className={labelClass}>Phone number</label>
|
|
215
|
-
<div className="flex rounded border border-
|
|
215
|
+
<div className="flex rounded-input border border-primary overflow-hidden focus-within:border-brand focus-within:ring-1 focus-within:ring-brand transition-colors">
|
|
216
216
|
<select
|
|
217
217
|
value={selectedCountry}
|
|
218
218
|
onChange={(e) => { setSelectedCountry(e.target.value); setPhoneValue(''); }}
|
|
219
|
-
className="border-r border-
|
|
219
|
+
className="border-r border-secondary bg-secondary px-2 py-2.5 text-sm text-secondary focus:outline-none"
|
|
220
220
|
aria-label="Country code"
|
|
221
221
|
>
|
|
222
222
|
{countryOptions.map((opt) => (
|
|
@@ -228,7 +228,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
228
228
|
value={phoneValue}
|
|
229
229
|
onChange={handlePhoneChange}
|
|
230
230
|
placeholder={nationalPlaceholder}
|
|
231
|
-
className="flex-1 px-3 py-2.5 text-sm text-
|
|
231
|
+
className="flex-1 px-3 py-2.5 text-sm text-primary placeholder-quaternary bg-transparent focus:outline-none"
|
|
232
232
|
/>
|
|
233
233
|
</div>
|
|
234
234
|
</div>
|
|
@@ -236,7 +236,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
236
236
|
<button
|
|
237
237
|
type="submit"
|
|
238
238
|
disabled={loading}
|
|
239
|
-
className="w-full rounded
|
|
239
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
240
240
|
>
|
|
241
241
|
{loading ? 'Looking you up…' : 'Continue'}
|
|
242
242
|
</button>
|
|
@@ -264,7 +264,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
264
264
|
<button
|
|
265
265
|
type="submit"
|
|
266
266
|
disabled={loading}
|
|
267
|
-
className="w-full rounded
|
|
267
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
268
268
|
>
|
|
269
269
|
{loading ? 'Signing in…' : 'Sign in'}
|
|
270
270
|
</button>
|
|
@@ -339,7 +339,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
339
339
|
<button
|
|
340
340
|
type="submit"
|
|
341
341
|
disabled={loading}
|
|
342
|
-
className="w-full rounded
|
|
342
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
343
343
|
>
|
|
344
344
|
{loading ? 'Creating account…' : 'Create account'}
|
|
345
345
|
</button>
|
|
@@ -67,12 +67,12 @@ export function MessageComposer({ contactId }: { contactId: number }) {
|
|
|
67
67
|
placeholder="Type a message… (Enter to send)"
|
|
68
68
|
rows={1}
|
|
69
69
|
disabled={isPending}
|
|
70
|
-
className="flex-1 resize-none rounded-
|
|
70
|
+
className="flex-1 resize-none rounded-input border border-primary bg-primary px-3 py-2 text-sm text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors disabled:opacity-50"
|
|
71
71
|
/>
|
|
72
72
|
<button
|
|
73
73
|
type="submit"
|
|
74
74
|
disabled={!body.trim() || isPending}
|
|
75
|
-
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-
|
|
75
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-interactive bg-brand-solid text-white transition-colors hover:bg-brand-solid_hover disabled:opacity-40 disabled:cursor-not-allowed"
|
|
76
76
|
aria-label="Send message"
|
|
77
77
|
>
|
|
78
78
|
{isPending ? (
|
|
@@ -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,
|
|
@@ -164,7 +165,7 @@ function LoginWall({ message, cta = 'Sign in' }: { message: string; cta?: string
|
|
|
164
165
|
<p className="text-sm font-medium text-primary">{message}</p>
|
|
165
166
|
<button
|
|
166
167
|
data-open-login-modal
|
|
167
|
-
className="mt-4 cursor-pointer rounded-
|
|
168
|
+
className="mt-4 cursor-pointer rounded-interactive bg-brand-solid px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
|
|
168
169
|
>
|
|
169
170
|
{cta}
|
|
170
171
|
</button>
|
|
@@ -174,7 +175,7 @@ function LoginWall({ message, cta = 'Sign in' }: { message: string; cta?: string
|
|
|
174
175
|
|
|
175
176
|
function EmptyState({ message }: { message: string }) {
|
|
176
177
|
return (
|
|
177
|
-
<div className="rounded-
|
|
178
|
+
<div className="rounded-component border border-secondary bg-secondary py-16 text-center">
|
|
178
179
|
<p className="text-sm text-tertiary">{message}</p>
|
|
179
180
|
</div>
|
|
180
181
|
);
|
|
@@ -213,7 +214,7 @@ function ServiceItemRow({
|
|
|
213
214
|
<Link
|
|
214
215
|
key={offer.id}
|
|
215
216
|
href={specialsHref}
|
|
216
|
-
className="inline-flex items-center rounded-
|
|
217
|
+
className="inline-flex items-center rounded-badge bg-secondary border border-brand px-2 py-0.5 text-xs font-medium text-brand-secondary hover:bg-secondary_hover transition-colors"
|
|
217
218
|
>
|
|
218
219
|
Special: {offer.name}
|
|
219
220
|
</Link>
|
|
@@ -222,7 +223,7 @@ function ServiceItemRow({
|
|
|
222
223
|
key={offer.id}
|
|
223
224
|
data-open-login-modal
|
|
224
225
|
data-login-redirect={specialsHref}
|
|
225
|
-
className="inline-flex items-center rounded-
|
|
226
|
+
className="inline-flex items-center rounded-badge bg-secondary border border-brand px-2 py-0.5 text-xs font-medium text-brand-secondary hover:bg-secondary_hover transition-colors cursor-pointer"
|
|
226
227
|
>
|
|
227
228
|
Special: {offer.name}
|
|
228
229
|
</button>
|
|
@@ -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-component 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-component 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}
|
|
@@ -365,7 +371,7 @@ function PackagesPanel({
|
|
|
365
371
|
<Link
|
|
366
372
|
key={offer.id}
|
|
367
373
|
href={specialsHref}
|
|
368
|
-
className="inline-flex items-center rounded-
|
|
374
|
+
className="inline-flex items-center rounded-badge bg-secondary border border-brand px-2.5 py-0.5 text-xs font-medium text-brand-secondary hover:bg-secondary_hover transition-colors"
|
|
369
375
|
>
|
|
370
376
|
Special: {offer.name}
|
|
371
377
|
</Link>
|
|
@@ -374,7 +380,7 @@ function PackagesPanel({
|
|
|
374
380
|
key={offer.id}
|
|
375
381
|
data-open-login-modal
|
|
376
382
|
data-login-redirect={specialsHref}
|
|
377
|
-
className="inline-flex items-center rounded-
|
|
383
|
+
className="inline-flex items-center rounded-badge bg-secondary border border-brand px-2.5 py-0.5 text-xs font-medium text-brand-secondary hover:bg-secondary_hover transition-colors cursor-pointer"
|
|
378
384
|
>
|
|
379
385
|
Special: {offer.name}
|
|
380
386
|
</button>
|
|
@@ -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-component 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
|
|
|
@@ -444,10 +454,10 @@ function MessagesPanel({
|
|
|
444
454
|
businessName;
|
|
445
455
|
|
|
446
456
|
return (
|
|
447
|
-
<div className="flex flex-col rounded-
|
|
457
|
+
<div className="flex flex-col rounded-component border border-secondary bg-primary overflow-hidden">
|
|
448
458
|
{/* Thread header */}
|
|
449
459
|
<div className="flex items-center gap-2.5 border-b border-secondary px-4 py-3 shrink-0">
|
|
450
|
-
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-
|
|
460
|
+
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-solid text-xs font-semibold text-white shrink-0">
|
|
451
461
|
{getInitials(threadBusiness)}
|
|
452
462
|
</div>
|
|
453
463
|
<span className="text-sm font-semibold text-primary">{threadBusiness}</span>
|
|
@@ -470,8 +480,8 @@ function MessagesPanel({
|
|
|
470
480
|
const isOutbound = m.direction === 'outbound';
|
|
471
481
|
return (
|
|
472
482
|
<div key={m.id} className={`flex ${isOutbound ? 'justify-start' : 'justify-end'}`}>
|
|
473
|
-
<div className={`max-w-[80%] rounded-
|
|
474
|
-
isOutbound ? 'bg-secondary text-primary
|
|
483
|
+
<div className={`max-w-[80%] rounded-component px-4 py-2.5 text-sm ${
|
|
484
|
+
isOutbound ? 'bg-secondary text-primary' : 'bg-brand-solid text-white'
|
|
475
485
|
}`}>
|
|
476
486
|
{m.sender_display_name && (
|
|
477
487
|
<p className="mb-0.5 text-xs font-medium opacity-60">{m.sender_display_name}</p>
|
|
@@ -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-component 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-interactive bg-brand-solid px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
|
|
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
|
|
|
@@ -643,7 +659,7 @@ export async function PortalPage({
|
|
|
643
659
|
<div className="flex items-center gap-3">
|
|
644
660
|
{consumerDisplayName && (
|
|
645
661
|
<div className="hidden sm:flex items-center gap-2 rounded-full border border-secondary bg-secondary px-3 py-1.5">
|
|
646
|
-
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-
|
|
662
|
+
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-solid text-[9px] font-bold text-white shrink-0">
|
|
647
663
|
{getInitials(consumerDisplayName)}
|
|
648
664
|
</div>
|
|
649
665
|
<span className="text-xs font-medium text-secondary max-w-[120px] truncate">
|
|
@@ -656,7 +672,7 @@ export async function PortalPage({
|
|
|
656
672
|
) : (
|
|
657
673
|
<button
|
|
658
674
|
data-open-login-modal
|
|
659
|
-
className="cursor-pointer rounded-
|
|
675
|
+
className="cursor-pointer rounded-interactive border border-secondary px-3 py-1.5 text-xs font-medium text-secondary hover:bg-secondary transition-colors"
|
|
660
676
|
>
|
|
661
677
|
Sign in
|
|
662
678
|
</button>
|
|
@@ -675,8 +691,8 @@ export async function PortalPage({
|
|
|
675
691
|
const isActive = tab === t.id;
|
|
676
692
|
const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
|
|
677
693
|
const href = `${portalHref}?tab=${t.id}`;
|
|
678
|
-
const className = `shrink-0 rounded-
|
|
679
|
-
isActive ? 'bg-
|
|
694
|
+
const className = `shrink-0 rounded-interactive px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
|
695
|
+
isActive ? 'bg-brand-solid text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
|
|
680
696
|
}`;
|
|
681
697
|
if (isGated) {
|
|
682
698
|
return (
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,7 @@ export function RowThumbnail({
|
|
|
26
26
|
|
|
27
27
|
if (list.length === 0) {
|
|
28
28
|
return (
|
|
29
|
-
<div className={`${sizeClassName} shrink-0 rounded-
|
|
29
|
+
<div className={`${sizeClassName} shrink-0 rounded-component bg-secondary border border-tertiary flex items-center justify-center`}>
|
|
30
30
|
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
31
31
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
|
32
32
|
</svg>
|
|
@@ -36,7 +36,7 @@ export function RowThumbnail({
|
|
|
36
36
|
|
|
37
37
|
if (list.length === 1) {
|
|
38
38
|
return (
|
|
39
|
-
<div className={`${sizeClassName} shrink-0 rounded-
|
|
39
|
+
<div className={`${sizeClassName} shrink-0 rounded-component overflow-hidden`}>
|
|
40
40
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
41
41
|
<img
|
|
42
42
|
src={list[0]!.url}
|
|
@@ -48,7 +48,7 @@ export function RowThumbnail({
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
|
-
|
|
51
|
+
<div className={`${sizeClassName} shrink-0 rounded-component overflow-hidden relative`}>
|
|
52
52
|
<div
|
|
53
53
|
className={`absolute inset-0 ${transitioning ? 'transition-opacity ease-in-out' : 'transition-none'}`}
|
|
54
54
|
style={{ opacity: transitioning ? 0 : 1, ...(transitioning ? CROSSFADE_STYLE : {}) }}
|
|
@@ -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');
|