keystone-design-bootstrap 1.0.59 → 1.0.61
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/design_system/sections/index.js +48 -2
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +48 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/portal/LoginForm.tsx +16 -12
- package/src/design_system/portal/MessageComposer.tsx +2 -2
- package/src/design_system/portal/PortalPage.tsx +19 -19
- package/src/design_system/portal/RowThumbnail.tsx +3 -3
- package/src/design_system/sections/contact-section-form.aman.tsx +2 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +2 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +2 -1
- package/src/design_system/sections/contact-section-form.tsx +2 -1
- package/src/next/routes/chat.ts +7 -1
- package/src/next/routes/consumer-auth.ts +6 -4
- package/src/next/routes/form.ts +3 -0
- package/src/next/routes/proxy-headers.ts +22 -0
- 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/MetaPixel.tsx +4 -0
- package/src/tracking/firePixelEvent.ts +80 -3
- package/src/tracking/index.ts +2 -2
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import React, { useState, useMemo } from 'react';
|
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { countries } from '../../utils/countries';
|
|
6
6
|
import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
|
|
7
|
-
|
|
7
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
8
8
|
type Step = 'identifier' | 'returning' | 'new';
|
|
9
9
|
|
|
10
10
|
interface LoginFormProps {
|
|
@@ -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 {
|
|
@@ -73,9 +73,13 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
73
73
|
});
|
|
74
74
|
const result = await res.json().catch(() => ({ exists: null }));
|
|
75
75
|
if (result.exists === true) {
|
|
76
|
+
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
77
|
+
firePixelEvent('Lead');
|
|
76
78
|
setWelcomeName(result.firstName ?? null);
|
|
77
79
|
setStep(result.hasPassword === false ? 'new' : 'returning');
|
|
78
80
|
} else if (result.exists === false) {
|
|
81
|
+
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
82
|
+
firePixelEvent('Lead');
|
|
79
83
|
setStep('new');
|
|
80
84
|
} else {
|
|
81
85
|
setError('Something went wrong. Please check your connection and try again.');
|
|
@@ -185,7 +189,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
185
189
|
</div>
|
|
186
190
|
|
|
187
191
|
{error && (
|
|
188
|
-
<div className="mb-4 rounded border border-
|
|
192
|
+
<div className="mb-4 rounded-input border border-error bg-error-primary px-3 py-2 text-sm text-error-primary">
|
|
189
193
|
{error}
|
|
190
194
|
</div>
|
|
191
195
|
)}
|
|
@@ -206,17 +210,17 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
206
210
|
/>
|
|
207
211
|
</div>
|
|
208
212
|
<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-
|
|
213
|
+
<hr className="flex-1 border-secondary" />
|
|
214
|
+
<span className="text-xs text-quaternary">or</span>
|
|
215
|
+
<hr className="flex-1 border-secondary" />
|
|
212
216
|
</div>
|
|
213
217
|
<div>
|
|
214
218
|
<label className={labelClass}>Phone number</label>
|
|
215
|
-
<div className="flex rounded border border-
|
|
219
|
+
<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
220
|
<select
|
|
217
221
|
value={selectedCountry}
|
|
218
222
|
onChange={(e) => { setSelectedCountry(e.target.value); setPhoneValue(''); }}
|
|
219
|
-
className="border-r border-
|
|
223
|
+
className="border-r border-secondary bg-secondary px-2 py-2.5 text-sm text-secondary focus:outline-none"
|
|
220
224
|
aria-label="Country code"
|
|
221
225
|
>
|
|
222
226
|
{countryOptions.map((opt) => (
|
|
@@ -228,7 +232,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
228
232
|
value={phoneValue}
|
|
229
233
|
onChange={handlePhoneChange}
|
|
230
234
|
placeholder={nationalPlaceholder}
|
|
231
|
-
className="flex-1 px-3 py-2.5 text-sm text-
|
|
235
|
+
className="flex-1 px-3 py-2.5 text-sm text-primary placeholder-quaternary bg-transparent focus:outline-none"
|
|
232
236
|
/>
|
|
233
237
|
</div>
|
|
234
238
|
</div>
|
|
@@ -236,7 +240,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
236
240
|
<button
|
|
237
241
|
type="submit"
|
|
238
242
|
disabled={loading}
|
|
239
|
-
className="w-full rounded
|
|
243
|
+
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
244
|
>
|
|
241
245
|
{loading ? 'Looking you up…' : 'Continue'}
|
|
242
246
|
</button>
|
|
@@ -264,7 +268,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
264
268
|
<button
|
|
265
269
|
type="submit"
|
|
266
270
|
disabled={loading}
|
|
267
|
-
className="w-full rounded
|
|
271
|
+
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
272
|
>
|
|
269
273
|
{loading ? 'Signing in…' : 'Sign in'}
|
|
270
274
|
</button>
|
|
@@ -339,7 +343,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
339
343
|
<button
|
|
340
344
|
type="submit"
|
|
341
345
|
disabled={loading}
|
|
342
|
-
className="w-full rounded
|
|
346
|
+
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
347
|
>
|
|
344
348
|
{loading ? 'Creating account…' : 'Create account'}
|
|
345
349
|
</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 ? (
|
|
@@ -165,7 +165,7 @@ function LoginWall({ message, cta = 'Sign in' }: { message: string; cta?: string
|
|
|
165
165
|
<p className="text-sm font-medium text-primary">{message}</p>
|
|
166
166
|
<button
|
|
167
167
|
data-open-login-modal
|
|
168
|
-
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"
|
|
169
169
|
>
|
|
170
170
|
{cta}
|
|
171
171
|
</button>
|
|
@@ -175,7 +175,7 @@ function LoginWall({ message, cta = 'Sign in' }: { message: string; cta?: string
|
|
|
175
175
|
|
|
176
176
|
function EmptyState({ message }: { message: string }) {
|
|
177
177
|
return (
|
|
178
|
-
<div className="rounded-
|
|
178
|
+
<div className="rounded-component border border-secondary bg-secondary py-16 text-center">
|
|
179
179
|
<p className="text-sm text-tertiary">{message}</p>
|
|
180
180
|
</div>
|
|
181
181
|
);
|
|
@@ -214,7 +214,7 @@ function ServiceItemRow({
|
|
|
214
214
|
<Link
|
|
215
215
|
key={offer.id}
|
|
216
216
|
href={specialsHref}
|
|
217
|
-
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"
|
|
218
218
|
>
|
|
219
219
|
Special: {offer.name}
|
|
220
220
|
</Link>
|
|
@@ -223,7 +223,7 @@ function ServiceItemRow({
|
|
|
223
223
|
key={offer.id}
|
|
224
224
|
data-open-login-modal
|
|
225
225
|
data-login-redirect={specialsHref}
|
|
226
|
-
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"
|
|
227
227
|
>
|
|
228
228
|
Special: {offer.name}
|
|
229
229
|
</button>
|
|
@@ -267,7 +267,7 @@ function ServicesPanel({
|
|
|
267
267
|
return (
|
|
268
268
|
<>
|
|
269
269
|
<PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} />
|
|
270
|
-
<div className="divide-y divide-tertiary rounded-
|
|
270
|
+
<div className="divide-y divide-tertiary rounded-component border border-secondary bg-primary overflow-hidden">
|
|
271
271
|
{activeServices.map((service) => (
|
|
272
272
|
<details key={service.id} className="group">
|
|
273
273
|
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-4 hover:bg-secondary transition-colors">
|
|
@@ -325,7 +325,7 @@ function PackagesPanel({
|
|
|
325
325
|
{packages.map((pkg) => {
|
|
326
326
|
const activeOffers = (pkg.offers ?? []).filter((o) => o.active !== false && !o.expired);
|
|
327
327
|
return (
|
|
328
|
-
<div key={pkg.id} className="group rounded-
|
|
328
|
+
<div key={pkg.id} className="group rounded-component border border-secondary bg-primary p-4 flex flex-col gap-3">
|
|
329
329
|
<div className="flex items-start gap-3">
|
|
330
330
|
<RowThumbnail
|
|
331
331
|
photoAttachments={pkg.photo_attachments}
|
|
@@ -371,7 +371,7 @@ function PackagesPanel({
|
|
|
371
371
|
<Link
|
|
372
372
|
key={offer.id}
|
|
373
373
|
href={specialsHref}
|
|
374
|
-
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"
|
|
375
375
|
>
|
|
376
376
|
Special: {offer.name}
|
|
377
377
|
</Link>
|
|
@@ -380,7 +380,7 @@ function PackagesPanel({
|
|
|
380
380
|
key={offer.id}
|
|
381
381
|
data-open-login-modal
|
|
382
382
|
data-login-redirect={specialsHref}
|
|
383
|
-
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"
|
|
384
384
|
>
|
|
385
385
|
Special: {offer.name}
|
|
386
386
|
</button>
|
|
@@ -408,7 +408,7 @@ function SpecialsPanel({ specials }: { specials: SpecialItem[] }) {
|
|
|
408
408
|
<PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} />
|
|
409
409
|
<div className="space-y-3">
|
|
410
410
|
{specials.map((special) => (
|
|
411
|
-
<div key={special.id} className="group flex items-start gap-3 rounded-
|
|
411
|
+
<div key={special.id} className="group flex items-start gap-3 rounded-component border border-secondary bg-primary px-4 py-4">
|
|
412
412
|
<RowThumbnail
|
|
413
413
|
photoAttachments={special.photoAttachments}
|
|
414
414
|
seed={`special-${special.id}`}
|
|
@@ -454,10 +454,10 @@ function MessagesPanel({
|
|
|
454
454
|
businessName;
|
|
455
455
|
|
|
456
456
|
return (
|
|
457
|
-
<div className="flex flex-col rounded-
|
|
457
|
+
<div className="flex flex-col rounded-component border border-secondary bg-primary overflow-hidden">
|
|
458
458
|
{/* Thread header */}
|
|
459
459
|
<div className="flex items-center gap-2.5 border-b border-secondary px-4 py-3 shrink-0">
|
|
460
|
-
<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">
|
|
461
461
|
{getInitials(threadBusiness)}
|
|
462
462
|
</div>
|
|
463
463
|
<span className="text-sm font-semibold text-primary">{threadBusiness}</span>
|
|
@@ -480,8 +480,8 @@ function MessagesPanel({
|
|
|
480
480
|
const isOutbound = m.direction === 'outbound';
|
|
481
481
|
return (
|
|
482
482
|
<div key={m.id} className={`flex ${isOutbound ? 'justify-start' : 'justify-end'}`}>
|
|
483
|
-
<div className={`max-w-[80%] rounded-
|
|
484
|
-
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'
|
|
485
485
|
}`}>
|
|
486
486
|
{m.sender_display_name && (
|
|
487
487
|
<p className="mb-0.5 text-xs font-medium opacity-60">{m.sender_display_name}</p>
|
|
@@ -522,7 +522,7 @@ function BookPanel({
|
|
|
522
522
|
return (
|
|
523
523
|
<>
|
|
524
524
|
<PortalTabTracker event="InitiateCheckout" />
|
|
525
|
-
<div className="rounded-
|
|
525
|
+
<div className="rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
|
|
526
526
|
<iframe
|
|
527
527
|
src={bookingHref}
|
|
528
528
|
className="w-full h-full"
|
|
@@ -549,7 +549,7 @@ function BookPanel({
|
|
|
549
549
|
href={bookingHref}
|
|
550
550
|
target="_blank"
|
|
551
551
|
rel="noopener noreferrer"
|
|
552
|
-
className="mt-5 inline-flex items-center gap-2 rounded-
|
|
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
553
|
>
|
|
554
554
|
{bookingLabel}
|
|
555
555
|
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
@@ -659,7 +659,7 @@ export async function PortalPage({
|
|
|
659
659
|
<div className="flex items-center gap-3">
|
|
660
660
|
{consumerDisplayName && (
|
|
661
661
|
<div className="hidden sm:flex items-center gap-2 rounded-full border border-secondary bg-secondary px-3 py-1.5">
|
|
662
|
-
<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">
|
|
663
663
|
{getInitials(consumerDisplayName)}
|
|
664
664
|
</div>
|
|
665
665
|
<span className="text-xs font-medium text-secondary max-w-[120px] truncate">
|
|
@@ -672,7 +672,7 @@ export async function PortalPage({
|
|
|
672
672
|
) : (
|
|
673
673
|
<button
|
|
674
674
|
data-open-login-modal
|
|
675
|
-
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"
|
|
676
676
|
>
|
|
677
677
|
Sign in
|
|
678
678
|
</button>
|
|
@@ -691,8 +691,8 @@ export async function PortalPage({
|
|
|
691
691
|
const isActive = tab === t.id;
|
|
692
692
|
const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
|
|
693
693
|
const href = `${portalHref}?tab=${t.id}`;
|
|
694
|
-
const className = `shrink-0 rounded-
|
|
695
|
-
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'
|
|
696
696
|
}`;
|
|
697
697
|
if (isGated) {
|
|
698
698
|
return (
|
|
@@ -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 { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -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 { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -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 { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -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 { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -69,6 +69,7 @@ export const ContactSectionForm = ({
|
|
|
69
69
|
setStatusMessage(result.message || successMessage);
|
|
70
70
|
formRef.current?.reset();
|
|
71
71
|
onSuccess?.();
|
|
72
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
72
73
|
firePixelEvent('Lead');
|
|
73
74
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
74
75
|
} else {
|
package/src/next/routes/chat.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// for route handlers. Instead, export a factory so the consuming app can pass in its
|
|
22
22
|
// own `NextResponse` (from its own `next/server`) while we keep the core logic shared.
|
|
23
23
|
|
|
24
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
25
|
+
|
|
24
26
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
25
27
|
const API_KEY = process.env.API_KEY || '';
|
|
26
28
|
|
|
@@ -48,7 +50,11 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
48
50
|
|
|
49
51
|
const response = await fetch(`${API_URL}/public/messages`, {
|
|
50
52
|
method: 'POST',
|
|
51
|
-
headers: {
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'X-API-Key': API_KEY,
|
|
56
|
+
...clientContextHeaders(request),
|
|
57
|
+
},
|
|
52
58
|
body: JSON.stringify({ identifier, contact_id, body: messageBody, display_name, page_url }),
|
|
53
59
|
});
|
|
54
60
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
|
|
18
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
18
19
|
|
|
19
20
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
21
|
type NextResponseLike = { json: (body: unknown, init?: ResponseInit) => any };
|
|
@@ -29,11 +30,12 @@ function getApiKey(): string {
|
|
|
29
30
|
return process.env.API_KEY || '';
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function apiHeaders(): Record<string, string> {
|
|
33
|
+
function apiHeaders(request?: Request): Record<string, string> {
|
|
33
34
|
const key = getApiKey();
|
|
34
35
|
return {
|
|
35
36
|
'Content-Type': 'application/json',
|
|
36
37
|
...(key ? { 'X-API-Key': key } : {}),
|
|
38
|
+
...(request ? clientContextHeaders(request) : {}),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -51,7 +53,7 @@ async function handleInitiate(request: Request, NR: NextResponseLike): Promise<R
|
|
|
51
53
|
try {
|
|
52
54
|
const res = await fetch(`${getApiUrl()}/consumer/auth/initiate`, {
|
|
53
55
|
method: 'POST',
|
|
54
|
-
headers: apiHeaders(),
|
|
56
|
+
headers: apiHeaders(request),
|
|
55
57
|
body: JSON.stringify({ email: email || undefined, phone: phone || undefined }),
|
|
56
58
|
});
|
|
57
59
|
|
|
@@ -82,7 +84,7 @@ async function handleLogin(request: Request, NR: NextResponseLike): Promise<Resp
|
|
|
82
84
|
|
|
83
85
|
const res = await fetch(`${getApiUrl()}/consumer/auth/login`, {
|
|
84
86
|
method: 'POST',
|
|
85
|
-
headers: apiHeaders(),
|
|
87
|
+
headers: apiHeaders(request),
|
|
86
88
|
body: JSON.stringify({ email: email || undefined, phone: phone || undefined, password }),
|
|
87
89
|
});
|
|
88
90
|
|
|
@@ -119,7 +121,7 @@ async function handleSignup(request: Request, NR: NextResponseLike): Promise<Res
|
|
|
119
121
|
|
|
120
122
|
const res = await fetch(`${getApiUrl()}/consumer/auth/signup`, {
|
|
121
123
|
method: 'POST',
|
|
122
|
-
headers: apiHeaders(),
|
|
124
|
+
headers: apiHeaders(request),
|
|
123
125
|
body: JSON.stringify({
|
|
124
126
|
email: email || undefined,
|
|
125
127
|
phone: phone || undefined,
|
package/src/next/routes/form.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// Export a factory so the consuming app can pass its own `NextResponse` (from its own
|
|
16
16
|
// `next/server`) to avoid type identity conflicts when used as a local file dependency.
|
|
17
17
|
|
|
18
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
19
|
+
|
|
18
20
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
19
21
|
const API_KEY = process.env.API_KEY || '';
|
|
20
22
|
|
|
@@ -38,6 +40,7 @@ export function createFormRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
38
40
|
headers: {
|
|
39
41
|
'Content-Type': 'application/json',
|
|
40
42
|
'X-API-Key': API_KEY,
|
|
43
|
+
...clientContextHeaders(request),
|
|
41
44
|
},
|
|
42
45
|
body: JSON.stringify(body),
|
|
43
46
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts the real client IP and user-agent from an incoming Next.js route
|
|
3
|
+
* request and returns them as headers to forward to the upstream Rails API.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare and other load-balancers set x-real-ip (or x-forwarded-for) on
|
|
6
|
+
* inbound requests before they reach the Next.js function. Without this, the
|
|
7
|
+
* Rails API sees the Next.js server IP instead of the real browser IP, which
|
|
8
|
+
* produces inaccurate server-side CAPI signals.
|
|
9
|
+
*
|
|
10
|
+
* Convention: forwarded as X-Real-Client-IP / X-Real-Client-UA so that Rails
|
|
11
|
+
* can read them explicitly without conflicting with its own proxy middleware.
|
|
12
|
+
*/
|
|
13
|
+
export function clientContextHeaders(request: Request): Record<string, string> {
|
|
14
|
+
const ip =
|
|
15
|
+
request.headers.get('x-real-ip') ||
|
|
16
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
17
|
+
const ua = request.headers.get('user-agent');
|
|
18
|
+
const headers: Record<string, string> = {};
|
|
19
|
+
if (ip) headers['X-Real-Client-IP'] = ip;
|
|
20
|
+
if (ua) headers['X-Real-Client-UA'] = ua;
|
|
21
|
+
return headers;
|
|
22
|
+
}
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
/* Border colors */
|
|
41
41
|
--color-border-primary: #E5E2D9;
|
|
42
42
|
--color-border-secondary: #D5D2C9;
|
|
43
|
+
|
|
44
|
+
/* Radius — minimal/sharp aesthetic */
|
|
45
|
+
--radius-component: 0.25rem;
|
|
46
|
+
--radius-interactive: 0.25rem;
|
|
47
|
+
--radius-input: 0.25rem;
|
|
48
|
+
--radius-badge: 0.25rem;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/* Body text uses Inter */
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
/* Border colors */
|
|
41
41
|
--color-border-primary: #EEEAE7;
|
|
42
42
|
--color-border-secondary: #E5E1DE;
|
|
43
|
+
|
|
44
|
+
/* Radius — soft/rounded aesthetic */
|
|
45
|
+
--radius-component: 0.75rem;
|
|
46
|
+
--radius-interactive: 0.5rem;
|
|
47
|
+
--radius-input: 0.5rem;
|
|
48
|
+
--radius-badge: 9999px;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/* Body text uses Poppins */
|
package/src/styles/theme.css
CHANGED
|
@@ -54,6 +54,12 @@
|
|
|
54
54
|
--radius-3xl: 1.5rem;
|
|
55
55
|
--radius-full: 9999px;
|
|
56
56
|
|
|
57
|
+
/* Semantic radius tokens — override per theme to get consistent rounding everywhere */
|
|
58
|
+
--radius-component: 0.5rem; /* cards, panels, containers */
|
|
59
|
+
--radius-interactive: 0.375rem; /* buttons, tabs */
|
|
60
|
+
--radius-input: 0.375rem; /* form inputs */
|
|
61
|
+
--radius-badge: 9999px; /* pill badges / tags */
|
|
62
|
+
|
|
57
63
|
/* ==================== SHADOWS ==================== */
|
|
58
64
|
--shadow-xs: 0px 1px 2px rgba(10, 13, 18, 0.05);
|
|
59
65
|
--shadow-sm: 0px 1px 3px rgba(10, 13, 18, 0.1), 0px 1px 2px -1px rgba(10, 13, 18, 0.1);
|
|
@@ -14,6 +14,10 @@ const PIXEL_SCRIPT = (pixelId: string) => `
|
|
|
14
14
|
s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');
|
|
15
15
|
fbq('init', '${pixelId.replace(/'/g, "\\'")}');
|
|
16
16
|
fbq('track', 'PageView');
|
|
17
|
+
window.__ks_pixel_ids = window.__ks_pixel_ids || [];
|
|
18
|
+
if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, "\\'")}') === -1) {
|
|
19
|
+
window.__ks_pixel_ids.push('${pixelId.replace(/'/g, "\\'")}');
|
|
20
|
+
}
|
|
17
21
|
`;
|
|
18
22
|
|
|
19
23
|
export type MetaPixelProps = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type FbqFn = (method: string,
|
|
1
|
+
type FbqFn = (method: string, ...args: unknown[]) => void;
|
|
2
2
|
|
|
3
3
|
export type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';
|
|
4
4
|
|
|
@@ -7,20 +7,97 @@ export interface PixelEventParams {
|
|
|
7
7
|
contentCategory?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Raw (unhashed) user identifiers for advanced matching. */
|
|
11
|
+
export interface PixelUserData {
|
|
12
|
+
email?: string | null;
|
|
13
|
+
phone?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hashed user data stored in sessionStorage for the duration of the browsing session.
|
|
17
|
+
// Keyed by a short namespace to avoid collisions.
|
|
18
|
+
const STORAGE_KEY = 'ks_pud';
|
|
19
|
+
|
|
20
|
+
async function sha256Hex(str: string): Promise<string> {
|
|
21
|
+
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
22
|
+
return Array.from(new Uint8Array(buffer))
|
|
23
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getFbq(): FbqFn | undefined {
|
|
28
|
+
if (typeof window === 'undefined') return undefined;
|
|
29
|
+
return (window as Window & { fbq?: FbqFn }).fbq;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the pixel IDs registered by MetaPixel via the inline init script. */
|
|
33
|
+
function getRegisteredPixelIds(): string[] {
|
|
34
|
+
return (window as Window & { __ks_pixel_ids?: string[] }).__ks_pixel_ids ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Apply stored hashed user data to every configured Meta Pixel. */
|
|
38
|
+
function applyStoredUserData(fbq: FbqFn): void {
|
|
39
|
+
try {
|
|
40
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
41
|
+
if (!raw) return;
|
|
42
|
+
const hashed = JSON.parse(raw) as Record<string, string>;
|
|
43
|
+
if (Object.keys(hashed).length === 0) return;
|
|
44
|
+
getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));
|
|
45
|
+
} catch {
|
|
46
|
+
// sessionStorage unavailable or JSON malformed — safe to ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hash and store user identifiers so they are automatically included in all
|
|
52
|
+
* subsequent pixel events for this browser session. Call this as soon as
|
|
53
|
+
* identity is known (e.g. contact form submission, portal login step 1).
|
|
54
|
+
*/
|
|
55
|
+
export async function setPixelUserData(userData: PixelUserData): Promise<void> {
|
|
56
|
+
const hashed: Record<string, string> = {};
|
|
57
|
+
|
|
58
|
+
if (userData.email) {
|
|
59
|
+
hashed.em = await sha256Hex(userData.email.trim().toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
if (userData.phone) {
|
|
62
|
+
const digits = userData.phone.replace(/\D/g, '');
|
|
63
|
+
if (digits) hashed.ph = await sha256Hex(digits);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (Object.keys(hashed).length === 0) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));
|
|
70
|
+
} catch {
|
|
71
|
+
// sessionStorage unavailable — still apply to fbq for this page load
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fbq = getFbq();
|
|
75
|
+
if (fbq) {
|
|
76
|
+
getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
10
80
|
/**
|
|
11
81
|
* Single entry point for all client-side Meta Pixel event fires.
|
|
82
|
+
* Automatically applies any stored user identity before firing so that Meta
|
|
83
|
+
* can match events to known users across the entire session.
|
|
12
84
|
* Silently no-ops if fbq is not loaded (pixel not configured for this site).
|
|
13
85
|
*/
|
|
14
86
|
export function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void {
|
|
15
|
-
|
|
16
|
-
const fbq = (window as Window & { fbq?: FbqFn }).fbq;
|
|
87
|
+
const fbq = getFbq();
|
|
17
88
|
if (!fbq) {
|
|
18
89
|
console.debug('[MetaPixel] skipped — fbq not loaded', { event });
|
|
19
90
|
return;
|
|
20
91
|
}
|
|
92
|
+
|
|
93
|
+
// Re-apply stored identity before every event so user data is included
|
|
94
|
+
// even on events that fire after a client-side navigation (PageView, ViewContent, etc.)
|
|
95
|
+
applyStoredUserData(fbq);
|
|
96
|
+
|
|
21
97
|
const normalized: Record<string, string> = {};
|
|
22
98
|
if (params?.contentName) normalized.content_name = params.contentName;
|
|
23
99
|
if (params?.contentCategory) normalized.content_category = params.contentCategory;
|
|
100
|
+
|
|
24
101
|
console.debug('[MetaPixel]', event, normalized);
|
|
25
102
|
fbq('track', event, Object.keys(normalized).length > 0 ? normalized : undefined);
|
|
26
103
|
}
|