keystone-design-bootstrap 1.0.63 → 1.0.64
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/components/DynamicFormFields.d.ts +15 -0
- package/dist/design_system/components/DynamicFormFields.js +5049 -0
- package/dist/design_system/components/DynamicFormFields.js.map +1 -0
- package/dist/design_system/sections/index.d.ts +2 -1
- package/dist/design_system/sections/index.js +78 -31
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/form-C94A_PX_.d.ts +36 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +78 -31
- package/dist/index.js.map +1 -1
- package/dist/lib/server-api.d.ts +6 -3
- package/dist/lib/server-api.js +4 -0
- package/dist/lib/server-api.js.map +1 -1
- package/dist/{package-ByCAZw9u.d.ts → package-DeHKpQp7.d.ts} +1 -30
- package/dist/tracking/index.d.ts +52 -0
- package/dist/tracking/index.js +175 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +2 -1
- package/src/design_system/components/DynamicFormFields.tsx +100 -24
- package/src/lib/server-api.ts +7 -1
- package/src/types/api/form.ts +7 -0
package/dist/lib/server-api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { F as FormDefinition } from '../form-C94A_PX_.js';
|
|
2
|
+
import { C as CompanyInformation, P as Package, S as Service } from '../package-DeHKpQp7.js';
|
|
2
3
|
import { W as WebsitePhotos } from '../website-photos-Cl1YqAno.js';
|
|
3
4
|
import '../photos-CmBdWiuZ.js';
|
|
4
5
|
|
|
@@ -38,7 +39,9 @@ declare function getSocialPosts(): Promise<unknown>;
|
|
|
38
39
|
declare function getPackages(): Promise<Package[] | null>;
|
|
39
40
|
declare function getPackage(slug: string): Promise<Package | null>;
|
|
40
41
|
declare function getTestimonials(): Promise<unknown>;
|
|
41
|
-
/** Form definition for dynamic form rendering
|
|
42
|
+
/** Form definition for dynamic form rendering, fetched by form_type. */
|
|
42
43
|
declare function getForm(formType: string): Promise<FormDefinition | null>;
|
|
44
|
+
/** Form definition for dynamic form rendering, fetched by numeric ID. */
|
|
45
|
+
declare function getFormById(id: number | string): Promise<FormDefinition | null>;
|
|
43
46
|
|
|
44
|
-
export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
|
|
47
|
+
export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getFormById, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
|
package/dist/lib/server-api.js
CHANGED
|
@@ -96,6 +96,9 @@ async function getTestimonials() {
|
|
|
96
96
|
async function getForm(formType) {
|
|
97
97
|
return serverFetch(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
|
|
98
98
|
}
|
|
99
|
+
async function getFormById(id) {
|
|
100
|
+
return serverFetch(`/public/form_by_id/${encodeURIComponent(id)}`, defaultOptions);
|
|
101
|
+
}
|
|
99
102
|
export {
|
|
100
103
|
getAdsConfig,
|
|
101
104
|
getBlogPost,
|
|
@@ -103,6 +106,7 @@ export {
|
|
|
103
106
|
getCompanyInformation,
|
|
104
107
|
getFAQs,
|
|
105
108
|
getForm,
|
|
109
|
+
getFormById,
|
|
106
110
|
getJobPosting,
|
|
107
111
|
getJobPostings,
|
|
108
112
|
getLocation,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { Package } from '../types/api/package';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices(): Promise<Service[] | null> {\n return serverFetch<Service[]>('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages(): Promise<Package[] | null> {\n return serverFetch<Package[]>('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string): Promise<Package | null> {\n return serverFetch<Package>(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering
|
|
1
|
+
{"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { Package } from '../types/api/package';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices(): Promise<Service[] | null> {\n return serverFetch<Service[]>('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages(): Promise<Package[] | null> {\n return serverFetch<Package[]>('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string): Promise<Package | null> {\n return serverFetch<Package>(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering, fetched by form_type. */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n/** Form definition for dynamic form rendering, fetched by numeric ID. */\nexport async function getFormById(id: number | string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/form_by_id/${encodeURIComponent(id)}`, defaultOptions);\n}\n\n"],"mappings":";AAUA,IAAM,UAAU,QAAQ,IAAI,WAAW;AACvC,IAAM,UAAU,QAAQ,IAAI,WAAW;AAOvC,eAAe,YAAe,UAAkB,UAAwB,CAAC,GAAsB;AAlB/F;AAmBE,QAAM,MAAM,GAAG,OAAO,GAAG,QAAQ;AAEjC,MAAI;AACF,UAAM,eAAiE;AAAA,MACrE,SAAS;AAAA,QACP,aAAa;AAAA,QACb,gBAAgB;AAAA,MAClB;AAAA,MACA,OAAO,QAAQ;AAAA,IACjB;AAEA,QAAI,QAAQ,YAAY;AACtB,mBAAa,OAAO,EAAE,YAAY,QAAQ,WAAW;AAAA,IACvD;AAEA,UAAM,WAAW,MAAM,MAAM,KAAK,YAAY;AAE9C,QAAI,CAAC,SAAS,IAAI;AAChB,cAAQ,MAAM,sBAAsB,SAAS,MAAM,QAAQ,QAAQ,EAAE;AACrE,aAAO;AAAA,IACT;AAEA,UAAM,OAAO,MAAM,SAAS,KAAK;AAGjC,YAAO,UAAK,SAAL,YAAa;AAAA,EACtB,SAAS,OAAO;AACd,YAAQ,MAAM,gCAAgC,QAAQ,KAAK,KAAK;AAChE,WAAO;AAAA,EACT;AACF;AAKO,IAAM,YAAY;AAAA,EACvB,KAAK,CAAc,UAAkB,YAA8C;AACjF,WAAO,YAAe,UAAU,WAAW,EAAE,YAAY,GAAG,CAAC;AAAA,EAC/D;AACF;AAGA,IAAM,iBAA+B,EAAE,YAAY,GAAG;AAEtD,eAAsB,wBAA4D;AAChF,SAAO,YAAgC,+BAA+B,cAAc;AACtF;AAGA,eAAsB,eAA2D;AAC/E,QAAM,OAAO,MAAM,YAAwC,sBAAsB,cAAc;AAC/F,SAAO,sBAAQ;AACjB;AAGO,SAAS,eAAe,WAAyE;AACtG,QAAM,KAAK,aAAa,OAAO,cAAc,YAAY,mBAAmB,aAAa,UAAU;AACnG,QAAM,MAAM,MAAM,QAAQ,OAAO,KAAK,OAAO,EAAE,EAAE,KAAK,IAAI;AAC1D,SAAO,QAAQ,MAAM,QAAQ,UAAU,QAAQ,KAAK,GAAG,IAAI,MAAM;AACnE;AAEA,eAAsB,cAAyC;AAC7D,SAAO,YAAuB,oBAAoB,cAAc;AAClE;AAEA,eAAsB,WAAW,MAAuC;AACtE,SAAO,YAAqB,4BAA4B,IAAI,IAAI,cAAc;AAChF;AAEA,eAAsB,eAAe;AACnC,SAAO,YAAY,qBAAqB,cAAc;AACxD;AAEA,eAAsB,YAAY,MAAc;AAC9C,SAAO,YAAY,6BAA6B,IAAI,IAAI,cAAc;AACxE;AAEA,eAAsB,aAAa;AACjC,SAAO,YAAY,mBAAmB,cAAc;AACtD;AAEA,eAAsB,UAAU;AAC9B,SAAO,YAAY,yBAAyB,cAAc;AAC5D;AAEA,eAAsB,eAAe;AACnC,SAAO,YAAY,sBAAsB,cAAc;AACzD;AAEA,eAAsB,YAAY,MAAc;AAC9C,SAAO,YAAY,8BAA8B,IAAI,IAAI,cAAc;AACzE;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAEA,eAAsB,mBAAkD;AACtE,SAAO,YAA2B,0BAA0B,cAAc;AAC5E;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAEA,eAAsB,cAAc,MAAc;AAChD,SAAO,YAAY,gCAAgC,IAAI,IAAI,cAAc;AAC3E;AAEA,eAAsB,iBAAiB;AACrC,SAAO,YAAY,wBAAwB,cAAc;AAC3D;AAGA,eAAsB,cAAyC;AAC7D,SAAO,YAAuB,oBAAoB,cAAc;AAClE;AAEA,eAAsB,WAAW,MAAuC;AACtE,SAAO,YAAqB,4BAA4B,mBAAmB,IAAI,CAAC,IAAI,cAAc;AACpG;AAGA,eAAsB,kBAAkB;AACtC,SAAO,WAAW;AACpB;AAGA,eAAsB,QAAQ,UAAkB;AAE9C,SAAO,YAA4B,iBAAiB,mBAAmB,QAAQ,CAAC,IAAI,cAAc;AACpG;AAGA,eAAsB,YAAY,IAAqB;AAErD,SAAO,YAA4B,sBAAsB,mBAAmB,EAAE,CAAC,IAAI,cAAc;AACnG;","names":[]}
|
|
@@ -96,35 +96,6 @@ interface CompanyInformation {
|
|
|
96
96
|
}
|
|
97
97
|
type CompanyInformationResponse = CompanyInformation | null;
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
* Form definition from API (GET /public/forms/:form_type).
|
|
101
|
-
* fields is an ordered array; each item is a single field or an array of fields (inline row). Max depth 2.
|
|
102
|
-
*/
|
|
103
|
-
interface FormFieldDefinition {
|
|
104
|
-
name: string;
|
|
105
|
-
type: string;
|
|
106
|
-
label: string;
|
|
107
|
-
required?: boolean;
|
|
108
|
-
placeholder?: string;
|
|
109
|
-
/** For hidden fields, optional value to submit. */
|
|
110
|
-
value?: string;
|
|
111
|
-
}
|
|
112
|
-
/** One item in the fields array: either a single field or a row of fields (inline). */
|
|
113
|
-
type FormFieldItem = FormFieldDefinition | FormFieldDefinition[];
|
|
114
|
-
interface FormDefinition {
|
|
115
|
-
id: number;
|
|
116
|
-
name: string;
|
|
117
|
-
form_type: string;
|
|
118
|
-
/** Ordered array; each element is a field or an array of fields (inline row). */
|
|
119
|
-
fields: FormFieldItem[];
|
|
120
|
-
settings?: Record<string, unknown>;
|
|
121
|
-
/** Business name for consent copy (e.g. "{{company_name}}" in checkbox labels). */
|
|
122
|
-
company_name?: string;
|
|
123
|
-
created_at?: string;
|
|
124
|
-
updated_at?: string;
|
|
125
|
-
}
|
|
126
|
-
type FormType = 'lead' | 'job_application' | 'marketing_list_signup';
|
|
127
|
-
|
|
128
99
|
interface PackageItem {
|
|
129
100
|
quantity: number;
|
|
130
101
|
service_item?: {
|
|
@@ -147,4 +118,4 @@ interface Package {
|
|
|
147
118
|
offers?: OfferPublic[];
|
|
148
119
|
}
|
|
149
120
|
|
|
150
|
-
export type { CompanyInformation as C,
|
|
121
|
+
export type { CompanyInformation as C, OfferPublic as O, Package as P, Service as S, CompanyInformationResponse as a, PackageItem as b, ServiceItem as c, ServiceParams as d, ServiceResponse as e };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
type MetaPixelProps = {
|
|
4
|
+
/** Meta Pixel ID. When null/undefined, nothing is rendered. */
|
|
5
|
+
pixelId: string | null | undefined;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Renders the Meta (Facebook) Pixel base code: loads fbevents.js, initializes
|
|
9
|
+
* the pixel, and tracks PageView. Use in the root layout when ads config
|
|
10
|
+
* provides a meta_pixel_id.
|
|
11
|
+
*/
|
|
12
|
+
declare function MetaPixel({ pixelId }: MetaPixelProps): React.JSX.Element | null;
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
/** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */
|
|
16
|
+
bookingUrl?: string | null;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Single client-side tracker placed once in KeystoneRootLayout.
|
|
20
|
+
* - Fires ViewContent on every route change for known page patterns.
|
|
21
|
+
* - Fires InitiateCheckout whenever a visitor clicks a link to the external booking URL.
|
|
22
|
+
*
|
|
23
|
+
* Portal booking tab tracking is handled directly inside PortalPage via PortalTabTracker
|
|
24
|
+
* since intent is only established when the Booking tab is explicitly opened.
|
|
25
|
+
*/
|
|
26
|
+
declare function MetaPixelTracker({ bookingUrl }: Props): null;
|
|
27
|
+
|
|
28
|
+
type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';
|
|
29
|
+
interface PixelEventParams {
|
|
30
|
+
contentName?: string;
|
|
31
|
+
contentCategory?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Raw (unhashed) user identifiers for advanced matching. */
|
|
34
|
+
interface PixelUserData {
|
|
35
|
+
email?: string | null;
|
|
36
|
+
phone?: string | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Hash and store user identifiers so they are automatically included in all
|
|
40
|
+
* subsequent pixel events for this browser session. Call this as soon as
|
|
41
|
+
* identity is known (e.g. contact form submission, portal login step 1).
|
|
42
|
+
*/
|
|
43
|
+
declare function setPixelUserData(userData: PixelUserData): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Single entry point for all client-side Meta Pixel event fires.
|
|
46
|
+
* Automatically applies any stored user identity before firing so that Meta
|
|
47
|
+
* can match events to known users across the entire session.
|
|
48
|
+
* Silently no-ops if fbq is not loaded (pixel not configured for this site).
|
|
49
|
+
*/
|
|
50
|
+
declare function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void;
|
|
51
|
+
|
|
52
|
+
export { MetaPixel, type MetaPixelProps, MetaPixelTracker, type PixelEvent, type PixelEventParams, type PixelUserData, firePixelEvent, setPixelUserData };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/tracking/MetaPixel.tsx
|
|
2
|
+
import Script from "next/script";
|
|
3
|
+
var FBEVENTS_URL = "https://connect.facebook.net/en_US/fbevents.js";
|
|
4
|
+
var PIXEL_SCRIPT = (pixelId) => `
|
|
5
|
+
!function(f,b,e,v,n,t,s)
|
|
6
|
+
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
7
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
8
|
+
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
|
9
|
+
n.queue=[];t=b.createElement(e);t.async=!0;
|
|
10
|
+
t.src=v;s=b.getElementsByTagName(e)[0];
|
|
11
|
+
s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');
|
|
12
|
+
fbq('init', '${pixelId.replace(/'/g, "\\'")}');
|
|
13
|
+
fbq('track', 'PageView');
|
|
14
|
+
window.__ks_pixel_ids = window.__ks_pixel_ids || [];
|
|
15
|
+
if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, "\\'")}') === -1) {
|
|
16
|
+
window.__ks_pixel_ids.push('${pixelId.replace(/'/g, "\\'")}');
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
function MetaPixel({ pixelId }) {
|
|
20
|
+
const raw = typeof pixelId === "string" ? pixelId.trim() : "";
|
|
21
|
+
const id = raw && raw !== "null" && /^\d+$/.test(raw) ? raw : "";
|
|
22
|
+
if (!id) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
|
|
26
|
+
Script,
|
|
27
|
+
{
|
|
28
|
+
id: "meta-pixel",
|
|
29
|
+
strategy: "afterInteractive",
|
|
30
|
+
dangerouslySetInnerHTML: { __html: PIXEL_SCRIPT(id) }
|
|
31
|
+
}
|
|
32
|
+
), /* @__PURE__ */ React.createElement("noscript", null, /* @__PURE__ */ React.createElement(
|
|
33
|
+
"img",
|
|
34
|
+
{
|
|
35
|
+
height: 1,
|
|
36
|
+
width: 1,
|
|
37
|
+
style: { display: "none" },
|
|
38
|
+
src: `https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1`,
|
|
39
|
+
alt: ""
|
|
40
|
+
}
|
|
41
|
+
)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/tracking/MetaPixelTracker.tsx
|
|
45
|
+
import { useEffect } from "react";
|
|
46
|
+
import { usePathname } from "next/navigation";
|
|
47
|
+
|
|
48
|
+
// src/tracking/firePixelEvent.ts
|
|
49
|
+
var STORAGE_KEY = "ks_pud";
|
|
50
|
+
async function sha256Hex(str) {
|
|
51
|
+
const buffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
|
|
52
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
53
|
+
}
|
|
54
|
+
function getFbq() {
|
|
55
|
+
if (typeof window === "undefined") return void 0;
|
|
56
|
+
return window.fbq;
|
|
57
|
+
}
|
|
58
|
+
function getRegisteredPixelIds() {
|
|
59
|
+
var _a;
|
|
60
|
+
return (_a = window.__ks_pixel_ids) != null ? _a : [];
|
|
61
|
+
}
|
|
62
|
+
function applyStoredUserData(fbq) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
65
|
+
if (!raw) return;
|
|
66
|
+
const hashed = JSON.parse(raw);
|
|
67
|
+
if (Object.keys(hashed).length === 0) return;
|
|
68
|
+
getRegisteredPixelIds().forEach((id) => fbq("init", id, hashed));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function setPixelUserData(userData) {
|
|
73
|
+
const hashed = {};
|
|
74
|
+
if (userData.email) {
|
|
75
|
+
hashed.em = await sha256Hex(userData.email.trim().toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
if (userData.phone) {
|
|
78
|
+
const digits = userData.phone.replace(/\D/g, "");
|
|
79
|
+
if (digits) hashed.ph = await sha256Hex(digits);
|
|
80
|
+
}
|
|
81
|
+
if (Object.keys(hashed).length === 0) return;
|
|
82
|
+
try {
|
|
83
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
}
|
|
86
|
+
const fbq = getFbq();
|
|
87
|
+
if (fbq) {
|
|
88
|
+
getRegisteredPixelIds().forEach((id) => fbq("init", id, hashed));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function firePixelEvent(event, params) {
|
|
92
|
+
const fbq = getFbq();
|
|
93
|
+
if (!fbq) {
|
|
94
|
+
console.debug("[MetaPixel] skipped \u2014 fbq not loaded", { event });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
applyStoredUserData(fbq);
|
|
98
|
+
const normalized = {};
|
|
99
|
+
if (params == null ? void 0 : params.contentName) normalized.content_name = params.contentName;
|
|
100
|
+
if (params == null ? void 0 : params.contentCategory) normalized.content_category = params.contentCategory;
|
|
101
|
+
console.debug("[MetaPixel]", event, normalized);
|
|
102
|
+
fbq("track", event, Object.keys(normalized).length > 0 ? normalized : void 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/tracking/MetaPixelTracker.tsx
|
|
106
|
+
function slugToTitle(slug) {
|
|
107
|
+
return slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
108
|
+
}
|
|
109
|
+
var ROUTE_RULES = [
|
|
110
|
+
{
|
|
111
|
+
pattern: /^\/services\/(.+)$/,
|
|
112
|
+
getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: "Service" })
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
pattern: /^\/locations\/(.+)$/,
|
|
116
|
+
getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: "Location" })
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
pattern: /^\/services$/,
|
|
120
|
+
getParams: () => ({ contentName: "Services", contentCategory: "Services" })
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
pattern: /^\/locations$/,
|
|
124
|
+
getParams: () => ({ contentName: "Locations", contentCategory: "Locations" })
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
pattern: /^\/portal$/,
|
|
128
|
+
getParams: () => ({ contentName: "Member Portal", contentCategory: "Pricing" })
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
pattern: /^\/service-menu$/,
|
|
132
|
+
getParams: () => ({ contentName: "Service Menu", contentCategory: "Pricing" })
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
pattern: /^\/faq$/,
|
|
136
|
+
getParams: () => ({ contentName: "FAQ", contentCategory: "FAQ" })
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
pattern: /^\/contact$/,
|
|
140
|
+
getParams: () => ({ contentName: "Contact", contentCategory: "Contact" })
|
|
141
|
+
}
|
|
142
|
+
];
|
|
143
|
+
function MetaPixelTracker({ bookingUrl }) {
|
|
144
|
+
const pathname = usePathname();
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
for (const rule of ROUTE_RULES) {
|
|
147
|
+
const match = pathname.match(rule.pattern);
|
|
148
|
+
if (match) {
|
|
149
|
+
const { contentName, contentCategory } = rule.getParams(match);
|
|
150
|
+
firePixelEvent("ViewContent", { contentName, contentCategory });
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}, [pathname]);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!bookingUrl) return;
|
|
157
|
+
const handleClick = (e) => {
|
|
158
|
+
var _a;
|
|
159
|
+
const anchor = e.target.closest("a");
|
|
160
|
+
if ((_a = anchor == null ? void 0 : anchor.href) == null ? void 0 : _a.startsWith(bookingUrl)) {
|
|
161
|
+
firePixelEvent("InitiateCheckout");
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
document.addEventListener("click", handleClick);
|
|
165
|
+
return () => document.removeEventListener("click", handleClick);
|
|
166
|
+
}, [bookingUrl]);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
export {
|
|
170
|
+
MetaPixel,
|
|
171
|
+
MetaPixelTracker,
|
|
172
|
+
firePixelEvent,
|
|
173
|
+
setPixelUserData
|
|
174
|
+
};
|
|
175
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/tracking/MetaPixel.tsx","../../src/tracking/MetaPixelTracker.tsx","../../src/tracking/firePixelEvent.ts"],"sourcesContent":["'use client';\n\nimport Script from 'next/script';\n\nconst FBEVENTS_URL = 'https://connect.facebook.net/en_US/fbevents.js';\n\nconst PIXEL_SCRIPT = (pixelId: string) => `\n !function(f,b,e,v,n,t,s)\n {if(f.fbq)return;n=f.fbq=function(){n.callMethod?\n n.callMethod.apply(n,arguments):n.queue.push(arguments)};\n if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';\n n.queue=[];t=b.createElement(e);t.async=!0;\n t.src=v;s=b.getElementsByTagName(e)[0];\n s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');\n fbq('init', '${pixelId.replace(/'/g, \"\\\\'\")}');\n fbq('track', 'PageView');\n window.__ks_pixel_ids = window.__ks_pixel_ids || [];\n if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, \"\\\\'\")}') === -1) {\n window.__ks_pixel_ids.push('${pixelId.replace(/'/g, \"\\\\'\")}');\n }\n`;\n\nexport type MetaPixelProps = {\n /** Meta Pixel ID. When null/undefined, nothing is rendered. */\n pixelId: string | null | undefined;\n};\n\n/**\n * Renders the Meta (Facebook) Pixel base code: loads fbevents.js, initializes\n * the pixel, and tracks PageView. Use in the root layout when ads config\n * provides a meta_pixel_id.\n */\nexport function MetaPixel({ pixelId }: MetaPixelProps) {\n const raw = typeof pixelId === 'string' ? pixelId.trim() : '';\n const id = raw && raw !== 'null' && /^\\d+$/.test(raw) ? raw : '';\n if (!id) {\n return null;\n }\n\n return (\n <>\n <Script\n id=\"meta-pixel\"\n strategy=\"afterInteractive\"\n dangerouslySetInnerHTML={{ __html: PIXEL_SCRIPT(id) }}\n />\n <noscript>\n {/* eslint-disable-next-line @next/next/no-img-element -- 1x1 tracking pixel for no-JS fallback; next/image not applicable in noscript */}\n <img\n height={1}\n width={1}\n style={{ display: 'none' }}\n src={`https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1`}\n alt=\"\"\n />\n </noscript>\n </>\n );\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { usePathname } from 'next/navigation';\nimport { firePixelEvent } from './firePixelEvent';\n\ntype RouteRule = {\n pattern: RegExp;\n getParams: (match: RegExpMatchArray) => { contentName: string; contentCategory: string };\n};\n\nfunction slugToTitle(slug: string): string {\n return slug\n .split('-')\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(' ');\n}\n\n// Checked in order — first match wins. More specific patterns come first.\nconst ROUTE_RULES: RouteRule[] = [\n {\n pattern: /^\\/services\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Service' }),\n },\n {\n pattern: /^\\/locations\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Location' }),\n },\n {\n pattern: /^\\/services$/,\n getParams: () => ({ contentName: 'Services', contentCategory: 'Services' }),\n },\n {\n pattern: /^\\/locations$/,\n getParams: () => ({ contentName: 'Locations', contentCategory: 'Locations' }),\n },\n {\n pattern: /^\\/portal$/,\n getParams: () => ({ contentName: 'Member Portal', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/service-menu$/,\n getParams: () => ({ contentName: 'Service Menu', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/faq$/,\n getParams: () => ({ contentName: 'FAQ', contentCategory: 'FAQ' }),\n },\n {\n pattern: /^\\/contact$/,\n getParams: () => ({ contentName: 'Contact', contentCategory: 'Contact' }),\n },\n];\n\ntype Props = {\n /** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */\n bookingUrl?: string | null;\n};\n\n/**\n * Single client-side tracker placed once in KeystoneRootLayout.\n * - Fires ViewContent on every route change for known page patterns.\n * - Fires InitiateCheckout whenever a visitor clicks a link to the external booking URL.\n *\n * Portal booking tab tracking is handled directly inside PortalPage via PortalTabTracker\n * since intent is only established when the Booking tab is explicitly opened.\n */\nexport function MetaPixelTracker({ bookingUrl }: Props) {\n const pathname = usePathname();\n\n useEffect(() => {\n for (const rule of ROUTE_RULES) {\n const match = pathname.match(rule.pattern);\n if (match) {\n const { contentName, contentCategory } = rule.getParams(match);\n firePixelEvent('ViewContent', { contentName, contentCategory });\n break;\n }\n }\n }, [pathname]);\n\n useEffect(() => {\n if (!bookingUrl) return;\n\n const handleClick = (e: MouseEvent) => {\n const anchor = (e.target as Element).closest('a');\n if (anchor?.href?.startsWith(bookingUrl)) {\n firePixelEvent('InitiateCheckout');\n }\n };\n\n document.addEventListener('click', handleClick);\n return () => document.removeEventListener('click', handleClick);\n }, [bookingUrl]);\n\n return null;\n}\n","type FbqFn = (method: string, ...args: unknown[]) => void;\n\nexport type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';\n\nexport interface PixelEventParams {\n contentName?: string;\n contentCategory?: string;\n}\n\n/** Raw (unhashed) user identifiers for advanced matching. */\nexport interface PixelUserData {\n email?: string | null;\n phone?: string | null;\n}\n\n// Hashed user data stored in sessionStorage for the duration of the browsing session.\n// Keyed by a short namespace to avoid collisions.\nconst STORAGE_KEY = 'ks_pud';\n\nasync function sha256Hex(str: string): Promise<string> {\n const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));\n return Array.from(new Uint8Array(buffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\nfunction getFbq(): FbqFn | undefined {\n if (typeof window === 'undefined') return undefined;\n return (window as Window & { fbq?: FbqFn }).fbq;\n}\n\n/** Read the pixel IDs registered by MetaPixel via the inline init script. */\nfunction getRegisteredPixelIds(): string[] {\n return (window as Window & { __ks_pixel_ids?: string[] }).__ks_pixel_ids ?? [];\n}\n\n/** Apply stored hashed user data to every configured Meta Pixel. */\nfunction applyStoredUserData(fbq: FbqFn): void {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return;\n const hashed = JSON.parse(raw) as Record<string, string>;\n if (Object.keys(hashed).length === 0) return;\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n } catch {\n // sessionStorage unavailable or JSON malformed — safe to ignore\n }\n}\n\n/**\n * Hash and store user identifiers so they are automatically included in all\n * subsequent pixel events for this browser session. Call this as soon as\n * identity is known (e.g. contact form submission, portal login step 1).\n */\nexport async function setPixelUserData(userData: PixelUserData): Promise<void> {\n const hashed: Record<string, string> = {};\n\n if (userData.email) {\n hashed.em = await sha256Hex(userData.email.trim().toLowerCase());\n }\n if (userData.phone) {\n const digits = userData.phone.replace(/\\D/g, '');\n if (digits) hashed.ph = await sha256Hex(digits);\n }\n\n if (Object.keys(hashed).length === 0) return;\n\n try {\n sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));\n } catch {\n // sessionStorage unavailable — still apply to fbq for this page load\n }\n\n const fbq = getFbq();\n if (fbq) {\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n }\n}\n\n/**\n * Single entry point for all client-side Meta Pixel event fires.\n * Automatically applies any stored user identity before firing so that Meta\n * can match events to known users across the entire session.\n * Silently no-ops if fbq is not loaded (pixel not configured for this site).\n */\nexport function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void {\n const fbq = getFbq();\n if (!fbq) {\n console.debug('[MetaPixel] skipped — fbq not loaded', { event });\n return;\n }\n\n // Re-apply stored identity before every event so user data is included\n // even on events that fire after a client-side navigation (PageView, ViewContent, etc.)\n applyStoredUserData(fbq);\n\n const normalized: Record<string, string> = {};\n if (params?.contentName) normalized.content_name = params.contentName;\n if (params?.contentCategory) normalized.content_category = params.contentCategory;\n\n console.debug('[MetaPixel]', event, normalized);\n fbq('track', event, Object.keys(normalized).length > 0 ? normalized : undefined);\n}\n"],"mappings":"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","names":[]}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Theme } from '../themes/index.js';
|
|
2
2
|
export { B as BlogPost, a as BlogPostAuthor, b as BlogPostParams, c as BlogPostResponse, d as BlogPostTag } from '../blog-post-vWzW8yFb.js';
|
|
3
|
-
export { C as CompanyInformation, a as CompanyInformationResponse,
|
|
3
|
+
export { C as CompanyInformation, a as CompanyInformationResponse, O as OfferPublic, P as Package, b as PackageItem, S as Service, c as ServiceItem, d as ServiceParams, e as ServiceResponse } from '../package-DeHKpQp7.js';
|
|
4
4
|
import { P as PhotoAttachment } from '../photos-CmBdWiuZ.js';
|
|
5
5
|
export { a as Photo } from '../photos-CmBdWiuZ.js';
|
|
6
|
+
export { F as FormDefinition, a as FormFieldDefinition, b as FormFieldItem, c as FormFieldOption, d as FormType } from '../form-C94A_PX_.js';
|
|
6
7
|
export { a as WebsitePhoto, W as WebsitePhotos, b as WebsitePhotosResponse } from '../website-photos-Cl1YqAno.js';
|
|
7
8
|
|
|
8
9
|
interface NavItem {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keystone-design-bootstrap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.64",
|
|
4
4
|
"description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./hooks": "./src/lib/hooks/index.ts",
|
|
14
14
|
"./contexts": "./src/contexts/index.ts",
|
|
15
15
|
"./tracking": "./src/tracking/index.ts",
|
|
16
|
+
"./components/DynamicFormFields": "./src/design_system/components/DynamicFormFields.tsx",
|
|
16
17
|
"./lib/server-api": "./src/lib/server-api.ts",
|
|
17
18
|
"./lib/cta-urls": "./src/lib/cta-urls.ts",
|
|
18
19
|
"./lib/component-registry": "./src/lib/component-registry.ts",
|
|
@@ -29,6 +29,62 @@ function allFieldsFlat(fields: FormDefinition['fields']): FormFieldDefinition[]
|
|
|
29
29
|
return out;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// ─── CheckboxGroup ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface CheckboxGroupProps {
|
|
35
|
+
field: FormFieldDefinition;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function CheckboxGroup({ field }: CheckboxGroupProps) {
|
|
39
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
40
|
+
const options = field.options ?? [];
|
|
41
|
+
|
|
42
|
+
const toggle = (value: string) => {
|
|
43
|
+
setSelected((prev) =>
|
|
44
|
+
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<fieldset>
|
|
50
|
+
<legend className="mb-2 font-body text-sm font-medium text-fg-primary">
|
|
51
|
+
{field.label}
|
|
52
|
+
{field.required && <span className="ml-1 text-error-500">*</span>}
|
|
53
|
+
</legend>
|
|
54
|
+
|
|
55
|
+
{/* Hidden input carries the comma-separated value on submit */}
|
|
56
|
+
<input
|
|
57
|
+
type="hidden"
|
|
58
|
+
name={field.name}
|
|
59
|
+
value={selected.join(',')}
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<div className="grid grid-cols-2 gap-x-8 gap-y-3">
|
|
63
|
+
{options.map((opt) => {
|
|
64
|
+
const id = `cbg-${field.name}-${opt.value}`;
|
|
65
|
+
const checked = selected.includes(opt.value);
|
|
66
|
+
return (
|
|
67
|
+
<div key={opt.value} className="flex items-center gap-3">
|
|
68
|
+
<input
|
|
69
|
+
type="checkbox"
|
|
70
|
+
id={id}
|
|
71
|
+
checked={checked}
|
|
72
|
+
onChange={() => toggle(opt.value)}
|
|
73
|
+
className="h-4 w-4 shrink-0 rounded border-secondary focus:ring-focus-ring"
|
|
74
|
+
/>
|
|
75
|
+
<label htmlFor={id} className="font-body text-sm text-tertiary cursor-pointer">
|
|
76
|
+
{opt.label}
|
|
77
|
+
</label>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</div>
|
|
82
|
+
</fieldset>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── renderField ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
32
88
|
function renderField(
|
|
33
89
|
field: FormFieldDefinition,
|
|
34
90
|
index: number,
|
|
@@ -44,6 +100,10 @@ function renderField(
|
|
|
44
100
|
const name = field.name ?? `field-${index}`;
|
|
45
101
|
const type = (field.type ?? 'text').toString().toLowerCase();
|
|
46
102
|
|
|
103
|
+
if (type === 'checkbox_group') {
|
|
104
|
+
return <CheckboxGroup key={name} field={field} />;
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
if (field.type === 'hidden') {
|
|
48
108
|
const val = field.value ?? '';
|
|
49
109
|
return <input key={name} type="hidden" name={name} value={val} />;
|
|
@@ -55,7 +115,7 @@ function renderField(
|
|
|
55
115
|
let labelWithCompany = companyNameClean
|
|
56
116
|
? labelRaw.replace(/\{\{company_name\}\}/gi, companyNameClean)
|
|
57
117
|
: labelRaw;
|
|
58
|
-
// Inject ToS/Privacy links as markdown so they render as links
|
|
118
|
+
// Inject ToS/Privacy links as markdown so they render as links
|
|
59
119
|
if (name === 'tos_privacy_consent' && privacyPolicyUrl && termsOfServiceUrl) {
|
|
60
120
|
labelWithCompany = labelWithCompany
|
|
61
121
|
.replace(/\*\*Terms of Service\*\*/gi, `**[Terms of Service](${termsOfServiceUrl})**`)
|
|
@@ -93,19 +153,13 @@ function renderField(
|
|
|
93
153
|
);
|
|
94
154
|
}
|
|
95
155
|
|
|
96
|
-
if (field.type === 'tel'
|
|
97
|
-
const countryOptions = countries.map((c) => ({
|
|
98
|
-
label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`,
|
|
99
|
-
value: c.code,
|
|
100
|
-
}));
|
|
156
|
+
if (field.type === 'tel') {
|
|
101
157
|
const country = countries.find((c) => c.code === selectedCountryPhone);
|
|
102
158
|
const nationalMask = getNationalMask(country);
|
|
103
159
|
const value = phoneValues[name] ?? '';
|
|
104
160
|
const placeholder = nationalMask ? nationalMask.replace(/#/g, '0') : (field.placeholder ?? '');
|
|
105
161
|
|
|
106
162
|
const handlePhoneChange = (valueOrEvent: unknown) => {
|
|
107
|
-
// Some input implementations call onChange with an event, others with a string value.
|
|
108
|
-
// Normalize defensively to avoid runtime errors.
|
|
109
163
|
const raw =
|
|
110
164
|
typeof valueOrEvent === 'string'
|
|
111
165
|
? valueOrEvent
|
|
@@ -122,23 +176,43 @@ function renderField(
|
|
|
122
176
|
setPhoneValues((prev) => ({ ...prev, [name]: formatted }));
|
|
123
177
|
};
|
|
124
178
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
179
|
+
if (showCountryCode) {
|
|
180
|
+
const countryOptions = countries.map((c) => ({
|
|
181
|
+
label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`,
|
|
182
|
+
value: c.code,
|
|
183
|
+
}));
|
|
184
|
+
return (
|
|
185
|
+
<InputGroup
|
|
186
|
+
key={name}
|
|
187
|
+
label={field.label}
|
|
188
|
+
isRequired={Boolean(field.required)}
|
|
189
|
+
size="md"
|
|
190
|
+
leadingAddon={
|
|
191
|
+
<NativeSelect
|
|
192
|
+
aria-label="Country code"
|
|
193
|
+
value={selectedCountryPhone}
|
|
194
|
+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
195
|
+
onCountryChange(e.currentTarget.value)
|
|
196
|
+
}
|
|
197
|
+
options={countryOptions}
|
|
198
|
+
/>
|
|
199
|
+
}
|
|
200
|
+
>
|
|
201
|
+
<InputBase
|
|
202
|
+
type="tel"
|
|
203
|
+
name={name}
|
|
204
|
+
value={value}
|
|
205
|
+
onChange={handlePhoneChange}
|
|
206
|
+
placeholder={placeholder}
|
|
207
|
+
size="md"
|
|
139
208
|
/>
|
|
140
|
-
|
|
141
|
-
|
|
209
|
+
</InputGroup>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// No country code selector — render a formatted phone input directly
|
|
214
|
+
return (
|
|
215
|
+
<InputGroup key={name} label={field.label} isRequired={Boolean(field.required)} size="md">
|
|
142
216
|
<InputBase
|
|
143
217
|
type="tel"
|
|
144
218
|
name={name}
|
|
@@ -180,6 +254,8 @@ function renderField(
|
|
|
180
254
|
);
|
|
181
255
|
}
|
|
182
256
|
|
|
257
|
+
// ─── DynamicFormFields ────────────────────────────────────────────────────────
|
|
258
|
+
|
|
183
259
|
export function DynamicFormFields({ form, jobSlug, privacyPolicyUrl, termsOfServiceUrl }: DynamicFormFieldsProps) {
|
|
184
260
|
const [selectedCountryPhone, setSelectedCountryPhone] = useState('US');
|
|
185
261
|
const [phoneValues, setPhoneValues] = useState<Record<string, string>>({});
|
package/src/lib/server-api.ts
CHANGED
|
@@ -144,9 +144,15 @@ export async function getTestimonials() {
|
|
|
144
144
|
return getReviews();
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/** Form definition for dynamic form rendering
|
|
147
|
+
/** Form definition for dynamic form rendering, fetched by form_type. */
|
|
148
148
|
export async function getForm(formType: string) {
|
|
149
149
|
type FormDefinition = import('../types/api/form').FormDefinition;
|
|
150
150
|
return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Form definition for dynamic form rendering, fetched by numeric ID. */
|
|
154
|
+
export async function getFormById(id: number | string) {
|
|
155
|
+
type FormDefinition = import('../types/api/form').FormDefinition;
|
|
156
|
+
return serverFetch<FormDefinition>(`/public/form_by_id/${encodeURIComponent(id)}`, defaultOptions);
|
|
157
|
+
}
|
|
158
|
+
|
package/src/types/api/form.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Form definition from API (GET /public/forms/:form_type).
|
|
3
3
|
* fields is an ordered array; each item is a single field or an array of fields (inline row). Max depth 2.
|
|
4
4
|
*/
|
|
5
|
+
export interface FormFieldOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export interface FormFieldDefinition {
|
|
6
11
|
name: string;
|
|
7
12
|
type: string;
|
|
@@ -10,6 +15,8 @@ export interface FormFieldDefinition {
|
|
|
10
15
|
placeholder?: string;
|
|
11
16
|
/** For hidden fields, optional value to submit. */
|
|
12
17
|
value?: string;
|
|
18
|
+
/** For checkbox_group fields: the selectable options. */
|
|
19
|
+
options?: FormFieldOption[];
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/** One item in the fields array: either a single field or a row of fields (inline). */
|