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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.59",
3
+ "version": "1.0.61",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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-gray-300 bg-primary px-3 py-2.5 text-sm text-primary placeholder-gray-400 focus:border-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-700 transition-colors';
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-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
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-gray-200" />
210
- <span className="text-xs text-gray-400">or</span>
211
- <hr className="flex-1 border-gray-200" />
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-gray-300 overflow-hidden focus-within:border-gray-700 focus-within:ring-1 focus-within:ring-gray-700 transition-colors">
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-gray-200 bg-gray-50 px-2 py-2.5 text-sm text-gray-700 focus:outline-none"
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-gray-900 placeholder-gray-400 bg-transparent focus:outline-none"
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 border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
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 border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
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 border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
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-xl border border-gray-300 bg-primary px-3 py-2 text-sm text-primary placeholder-gray-400 focus:border-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-700 transition-colors disabled:opacity-50"
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-xl bg-gray-900 text-white transition-colors hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed"
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-lg bg-gray-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
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-xl border border-secondary bg-secondary py-16 text-center">
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-full bg-brand-50 border border-brand-200 px-2 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors"
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-full bg-brand-50 border border-brand-200 px-2 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors cursor-pointer"
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-xl border border-secondary bg-primary overflow-hidden">
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-xl border border-secondary bg-primary p-4 flex flex-col gap-3">
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-full bg-brand-50 border border-brand-200 px-2.5 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors"
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-full bg-brand-50 border border-brand-200 px-2.5 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors cursor-pointer"
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-xl border border-secondary bg-primary px-4 py-4">
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-xl border border-secondary bg-primary overflow-hidden">
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-gray-900 text-xs font-semibold text-white shrink-0">
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-2xl px-4 py-2.5 text-sm ${
484
- isOutbound ? 'bg-secondary text-primary rounded-tl-sm' : 'bg-gray-900 text-white rounded-tr-sm'
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-xl border border-secondary overflow-hidden" style={{ height: '70vh' }}>
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-lg bg-gray-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
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-gray-900 text-[9px] font-bold text-white shrink-0">
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-lg border border-secondary px-3 py-1.5 text-xs font-medium text-secondary hover:bg-secondary transition-colors"
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-lg px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
695
- isActive ? 'bg-gray-900 text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
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-lg bg-secondary border border-tertiary flex items-center justify-center`}>
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-lg overflow-hidden`}>
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
- <div className={`${sizeClassName} shrink-0 rounded-lg overflow-hidden relative`}>
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 {
@@ -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: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
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,
@@ -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 */
@@ -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, eventName: string, params?: Record<string, string>) => void;
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
- if (typeof window === 'undefined') return;
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
  }