keystone-design-bootstrap 1.0.69 → 1.0.71

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.
@@ -22,6 +22,14 @@ declare function getAdsConfig(): Promise<{
22
22
  declare function getMetaPixelId(adsConfig: {
23
23
  meta_pixel_id?: string;
24
24
  } | null | undefined): string | null;
25
+ type AnalyticsConfig = {
26
+ /** PostHog project API key — platform-wide, from POSTHOG_API_KEY env var on the Rails server. */
27
+ posthog_api_key?: string;
28
+ };
29
+ /** Analytics config for customer sites. Returns platform-wide analytics keys (e.g. PostHog). */
30
+ declare function getAnalyticsConfig(): Promise<AnalyticsConfig | null>;
31
+ /** Extract PostHog API key from analytics config for use with <PostHogProvider apiKey={...} />. */
32
+ declare function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undefined): string | null;
25
33
  declare function getServices(): Promise<Service[] | null>;
26
34
  declare function getService(slug: string): Promise<Service | null>;
27
35
  declare function getLocations(): Promise<unknown>;
@@ -44,4 +52,4 @@ declare function getForm(formType: string): Promise<FormDefinition | null>;
44
52
  /** Form definition for dynamic form rendering, fetched by numeric ID. */
45
53
  declare function getFormById(id: number | string): Promise<FormDefinition | null>;
46
54
 
47
- export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getFormById, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
55
+ export { type AnalyticsConfig, getAdsConfig, getAnalyticsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getFormById, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getPackage, getPackages, getPostHogApiKey, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
@@ -45,6 +45,15 @@ function getMetaPixelId(adsConfig) {
45
45
  const str = id != null && id !== "" ? String(id).trim() : "";
46
46
  return str !== "" && str !== "null" && /^\d+$/.test(str) ? str : null;
47
47
  }
48
+ async function getAnalyticsConfig() {
49
+ const data = await serverFetch("/public/analytics_config", defaultOptions);
50
+ return data != null ? data : null;
51
+ }
52
+ function getPostHogApiKey(analyticsConfig) {
53
+ const key = analyticsConfig == null ? void 0 : analyticsConfig.posthog_api_key;
54
+ const str = key != null && key !== "" ? String(key).trim() : "";
55
+ return str !== "" && str !== "null" ? str : null;
56
+ }
48
57
  async function getServices() {
49
58
  return serverFetch("/public/services", defaultOptions);
50
59
  }
@@ -101,6 +110,7 @@ async function getFormById(id) {
101
110
  }
102
111
  export {
103
112
  getAdsConfig,
113
+ getAnalyticsConfig,
104
114
  getBlogPost,
105
115
  getBlogPosts,
106
116
  getCompanyInformation,
@@ -114,6 +124,7 @@ export {
114
124
  getMetaPixelId,
115
125
  getPackage,
116
126
  getPackages,
127
+ getPostHogApiKey,
117
128
  getReviews,
118
129
  getService,
119
130
  getServices,
@@ -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, 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":[]}
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 type AnalyticsConfig = {\n /** PostHog project API key — platform-wide, from POSTHOG_API_KEY env var on the Rails server. */\n posthog_api_key?: string;\n};\n\n/** Analytics config for customer sites. Returns platform-wide analytics keys (e.g. PostHog). */\nexport async function getAnalyticsConfig(): Promise<AnalyticsConfig | null> {\n const data = await serverFetch<AnalyticsConfig>('/public/analytics_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract PostHog API key from analytics config for use with <PostHogProvider apiKey={...} />. */\nexport function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undefined): string | null {\n const key = analyticsConfig?.posthog_api_key;\n const str = key != null && key !== '' ? String(key).trim() : '';\n return str !== '' && str !== 'null' ? 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;AAQA,eAAsB,qBAAsD;AAC1E,QAAM,OAAO,MAAM,YAA6B,4BAA4B,cAAc;AAC1F,SAAO,sBAAQ;AACjB;AAGO,SAAS,iBAAiB,iBAAoE;AACnG,QAAM,MAAM,mDAAiB;AAC7B,QAAM,MAAM,OAAO,QAAQ,QAAQ,KAAK,OAAO,GAAG,EAAE,KAAK,IAAI;AAC7D,SAAO,QAAQ,MAAM,QAAQ,SAAS,MAAM;AAC9C;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":[]}
@@ -1,4 +1,4 @@
1
- import * as React from 'react';
1
+ import * as React$1 from 'react';
2
2
 
3
3
  type MetaPixelProps = {
4
4
  /** Meta Pixel ID. When null/undefined, nothing is rendered. */
@@ -9,9 +9,9 @@ type MetaPixelProps = {
9
9
  * the pixel, and tracks PageView. Use in the root layout when ads config
10
10
  * provides a meta_pixel_id.
11
11
  */
12
- declare function MetaPixel({ pixelId }: MetaPixelProps): React.JSX.Element | null;
12
+ declare function MetaPixel({ pixelId }: MetaPixelProps): React$1.JSX.Element | null;
13
13
 
14
- type Props = {
14
+ type Props$1 = {
15
15
  /** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */
16
16
  bookingUrl?: string | null;
17
17
  };
@@ -23,7 +23,7 @@ type Props = {
23
23
  * Portal booking tab tracking is handled directly inside PortalPage via PortalTabTracker
24
24
  * since intent is only established when the Booking tab is explicitly opened.
25
25
  */
26
- declare function MetaPixelTracker({ bookingUrl }: Props): null;
26
+ declare function MetaPixelTracker({ bookingUrl }: Props$1): null;
27
27
 
28
28
  type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';
29
29
  interface PixelEventParams {
@@ -54,4 +54,133 @@ declare function setPixelUserData(userData: PixelUserData): Promise<void>;
54
54
  */
55
55
  declare function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): void;
56
56
 
57
- export { MetaPixel, type MetaPixelProps, MetaPixelTracker, type PixelEvent, type PixelEventParams, type PixelUserData, firePixelEvent, setPixelUserData };
57
+ type PostHogProviderProps = {
58
+ apiKey: string;
59
+ apiHost?: string;
60
+ /** Keystone account ID — attached to every event as a super property. */
61
+ accountId?: number;
62
+ /** Keystone account name (company_name) — attached to every event as a super property. */
63
+ accountName?: string;
64
+ children: React.ReactNode;
65
+ };
66
+ /**
67
+ * Initialises PostHog, registers account-level super properties, and fires
68
+ * an enriched `$pageview` on every App Router navigation.
69
+ *
70
+ * Super properties attached to every event automatically:
71
+ * - account_id (Keystone account ID)
72
+ * - account_name (company_name)
73
+ * - site_domain (window.location.hostname)
74
+ *
75
+ * Mount once in the root layout body. One project key covers all customer
76
+ * sites — filter by account_name or site_domain in the PostHog dashboard.
77
+ */
78
+ declare function PostHogProvider({ apiKey, apiHost, accountId, accountName, children }: PostHogProviderProps): React$1.JSX.Element;
79
+
80
+ type Props = {
81
+ /** External booking / portal URL. When set, fires booking_cta_clicked on any click to that URL. */
82
+ bookingUrl?: string | null;
83
+ };
84
+ /**
85
+ * Page-level PostHog event tracker. Mount once inside PostHogProvider in
86
+ * KeystoneRootLayout alongside MetaPixelTracker.
87
+ *
88
+ * Responsibilities:
89
+ * - booking_cta_clicked: fires when any link to the booking URL is clicked,
90
+ * capturing the page the visitor was on when they clicked.
91
+ */
92
+ declare function KeystoneAnalyticsTracker({ bookingUrl }: Props): null;
93
+
94
+ /**
95
+ * PostHog event capture — Keystone customer sites.
96
+ *
97
+ * ## Naming convention
98
+ * All events use snake_case `object_action` format.
99
+ * Properties use snake_case as well.
100
+ *
101
+ * ## Event taxonomy
102
+ * Add new events here: define the name in `KsEventName` and its required
103
+ * properties in `KsEventProperties`. Every callsite is then type-checked.
104
+ *
105
+ * ## Usage
106
+ *
107
+ * import { captureEvent } from 'keystone-design-bootstrap/tracking';
108
+ *
109
+ * captureEvent('form_submitted', { form_type: 'lead' });
110
+ * captureEvent('booking_cta_clicked', { source_path: '/services/massage', booking_url: url });
111
+ *
112
+ * All calls are safe no-ops when PostHog has not been initialised (e.g. no
113
+ * POSTHOG_API_KEY configured on the server).
114
+ *
115
+ * ## Super properties
116
+ * account_id, account_name, and site_domain are registered as super properties
117
+ * by PostHogProvider and are automatically attached to every event — you do not
118
+ * need to include them in individual captureEvent calls.
119
+ */
120
+ type KsEventName = 'page_viewed' | 'booking_cta_clicked' | 'form_submitted' | 'form_failed' | 'chat_opened' | 'chat_message_sent' | 'chat_message_failed' | 'portal_tab_viewed' | 'portal_login_started' | 'portal_login_identified' | 'portal_login_completed' | 'portal_login_failed';
121
+ type KsEventProperties = {
122
+ /** Fired on every page navigation. $pageview is also fired for PostHog web analytics. */
123
+ page_viewed: {
124
+ page_name: string;
125
+ page_path: string;
126
+ page_slug?: string;
127
+ };
128
+ /** Fired when a visitor clicks any CTA that links to the external booking URL. */
129
+ booking_cta_clicked: {
130
+ source_path: string;
131
+ booking_url: string;
132
+ };
133
+ /** Fired when a Keystone form is successfully submitted. */
134
+ form_submitted: {
135
+ /** One of: lead | job_application | marketing_list_signup */
136
+ form_type: string;
137
+ /** Server-generated event ID for CAPI deduplication (when present). */
138
+ event_id?: string;
139
+ };
140
+ /** Fired when a form submission fails (validation error or network error). */
141
+ form_failed: {
142
+ form_type: string;
143
+ error: string;
144
+ };
145
+ /** Fired when the chat widget is first opened by the visitor. */
146
+ chat_opened: Record<string, never>;
147
+ /** Fired when a chat message is successfully sent. */
148
+ chat_message_sent: {
149
+ /** Whether the visitor is authenticated (contactId present). */
150
+ is_authenticated: boolean;
151
+ };
152
+ /** Fired when a chat message fails to send. */
153
+ chat_message_failed: {
154
+ error: string;
155
+ };
156
+ /** Fired when a member portal tab is opened. */
157
+ portal_tab_viewed: {
158
+ tab: string;
159
+ };
160
+ /** Fired when the portal login modal is opened / login flow starts. */
161
+ portal_login_started: Record<string, never>;
162
+ /**
163
+ * Fired after the identifier step resolves — we know whether the user
164
+ * already has an account.
165
+ */
166
+ portal_login_identified: {
167
+ method: 'email' | 'phone' | 'email_and_phone';
168
+ user_exists: boolean;
169
+ };
170
+ /** Fired after the user successfully signs in or creates an account. */
171
+ portal_login_completed: {
172
+ flow: 'signin' | 'signup';
173
+ };
174
+ /** Fired when any step of the login flow returns an error. */
175
+ portal_login_failed: {
176
+ step: 'identifier' | 'signin' | 'signup';
177
+ reason: string;
178
+ };
179
+ };
180
+ /**
181
+ * Captures a typed Keystone analytics event via PostHog.
182
+ * Safe no-op when PostHog has not been initialised.
183
+ */
184
+ declare function captureEvent<E extends KsEventName>(event: E, ...args: KsEventProperties[E] extends Record<string, never> ? [] : [properties: KsEventProperties[E]]): void;
185
+
186
+ export { KeystoneAnalyticsTracker, type KsEventName, type KsEventProperties, MetaPixel, type MetaPixelProps, MetaPixelTracker, type PixelEvent, type PixelEventParams, type PixelUserData, PostHogProvider, type PostHogProviderProps, captureEvent, firePixelEvent, setPixelUserData };
@@ -1,3 +1,23 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defProps = Object.defineProperties;
3
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
7
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
8
+ var __spreadValues = (a, b) => {
9
+ for (var prop in b || (b = {}))
10
+ if (__hasOwnProp.call(b, prop))
11
+ __defNormalProp(a, prop, b[prop]);
12
+ if (__getOwnPropSymbols)
13
+ for (var prop of __getOwnPropSymbols(b)) {
14
+ if (__propIsEnum.call(b, prop))
15
+ __defNormalProp(a, prop, b[prop]);
16
+ }
17
+ return a;
18
+ };
19
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
20
+
1
21
  // src/tracking/MetaPixel.tsx
2
22
  import Script from "next/script";
3
23
  var FBEVENTS_URL = "https://connect.facebook.net/en_US/fbevents.js";
@@ -168,9 +188,112 @@ function MetaPixelTracker({ bookingUrl }) {
168
188
  }, [bookingUrl]);
169
189
  return null;
170
190
  }
191
+
192
+ // src/tracking/PostHogProvider.tsx
193
+ import posthog from "posthog-js";
194
+ import { PostHogProvider as PHProvider } from "posthog-js/react";
195
+ import { useEffect as useEffect2, Suspense } from "react";
196
+ import { usePathname as usePathname2, useSearchParams } from "next/navigation";
197
+ var DEFAULT_HOST = "https://us.i.posthog.com";
198
+ function resolvePageInfo(pathname) {
199
+ var _a;
200
+ if (pathname === "/") return { page_name: "home" };
201
+ const patterns = [
202
+ [/^\/services\/(.+)$/, ([, slug]) => ({ page_name: "service_detail", page_slug: slug })],
203
+ [/^\/locations\/(.+)$/, ([, slug]) => ({ page_name: "location_detail", page_slug: slug })],
204
+ [/^\/blog\/(.+)$/, ([, slug]) => ({ page_name: "blog_post", page_slug: slug })],
205
+ [/^\/jobs\/(.+)$/, ([, slug]) => ({ page_name: "job_detail", page_slug: slug })],
206
+ [/^\/packages\/(.+)$/, ([, slug]) => ({ page_name: "package_detail", page_slug: slug })]
207
+ ];
208
+ for (const [pattern, resolve] of patterns) {
209
+ const match = pathname.match(pattern);
210
+ if (match) return resolve(match);
211
+ }
212
+ const staticNames = {
213
+ "/services": "services",
214
+ "/locations": "locations",
215
+ "/contact": "contact",
216
+ "/about": "about",
217
+ "/blog": "blog",
218
+ "/portal": "portal",
219
+ "/gallery": "gallery",
220
+ "/team": "team",
221
+ "/faq": "faq",
222
+ "/reviews": "reviews",
223
+ "/jobs": "jobs",
224
+ "/packages": "packages",
225
+ "/service-menu": "service_menu",
226
+ "/privacy-policy": "privacy_policy",
227
+ "/terms": "terms_of_service"
228
+ };
229
+ return { page_name: (_a = staticNames[pathname]) != null ? _a : "unknown" };
230
+ }
231
+ function PostHogPageviewTracker() {
232
+ const pathname = usePathname2();
233
+ const searchParams = useSearchParams();
234
+ useEffect2(() => {
235
+ if (!pathname) return;
236
+ const search = searchParams == null ? void 0 : searchParams.toString();
237
+ const url = window.location.origin + pathname + (search ? `?${search}` : "");
238
+ const { page_name, page_slug } = resolvePageInfo(pathname);
239
+ posthog.capture("$pageview", __spreadValues({
240
+ $current_url: url,
241
+ page_name,
242
+ page_path: pathname
243
+ }, page_slug && { page_slug }));
244
+ }, [pathname, searchParams]);
245
+ return null;
246
+ }
247
+ function PostHogProvider({ apiKey, apiHost, accountId, accountName, children }) {
248
+ useEffect2(() => {
249
+ posthog.init(apiKey, {
250
+ api_host: apiHost != null ? apiHost : DEFAULT_HOST,
251
+ person_profiles: "identified_only",
252
+ capture_pageview: false
253
+ });
254
+ posthog.register(__spreadProps(__spreadValues(__spreadValues({}, accountId !== void 0 && { account_id: accountId }), accountName && { account_name: accountName }), {
255
+ site_domain: window.location.hostname
256
+ }));
257
+ }, [apiKey, apiHost, accountId, accountName]);
258
+ return /* @__PURE__ */ React.createElement(PHProvider, { client: posthog }, /* @__PURE__ */ React.createElement(Suspense, { fallback: null }, /* @__PURE__ */ React.createElement(PostHogPageviewTracker, null)), children);
259
+ }
260
+
261
+ // src/tracking/KeystoneAnalyticsTracker.tsx
262
+ import { useEffect as useEffect3 } from "react";
263
+ import { usePathname as usePathname3 } from "next/navigation";
264
+
265
+ // src/tracking/captureEvent.ts
266
+ import posthog2 from "posthog-js";
267
+ function captureEvent(event, ...args) {
268
+ posthog2.capture(event, args[0]);
269
+ }
270
+
271
+ // src/tracking/KeystoneAnalyticsTracker.tsx
272
+ function KeystoneAnalyticsTracker({ bookingUrl }) {
273
+ const pathname = usePathname3();
274
+ useEffect3(() => {
275
+ if (!bookingUrl) return;
276
+ const handleClick = (e) => {
277
+ var _a;
278
+ const anchor = e.target.closest("a");
279
+ if ((_a = anchor == null ? void 0 : anchor.href) == null ? void 0 : _a.startsWith(bookingUrl)) {
280
+ captureEvent("booking_cta_clicked", {
281
+ source_path: pathname,
282
+ booking_url: bookingUrl
283
+ });
284
+ }
285
+ };
286
+ document.addEventListener("click", handleClick);
287
+ return () => document.removeEventListener("click", handleClick);
288
+ }, [bookingUrl, pathname]);
289
+ return null;
290
+ }
171
291
  export {
292
+ KeystoneAnalyticsTracker,
172
293
  MetaPixel,
173
294
  MetaPixelTracker,
295
+ PostHogProvider,
296
+ captureEvent,
174
297
  firePixelEvent,
175
298
  setPixelUserData
176
299
  };
@@ -1 +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 *\n * @param eventId - Optional server-side event ID for browser/server deduplication.\n * Pass the `eventId` returned by the form submission API so Meta can match and\n * deduplicate the browser Lead event against the server-side CAPI Lead event.\n * Format: fbq('track', event, params, { eventID: eventId })\n */\nexport function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): 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 const customData = Object.keys(normalized).length > 0 ? normalized : undefined;\n const eventData = eventId ? { eventID: eventId } : undefined;\n\n console.debug('[MetaPixel]', event, normalized, eventId ? { eventID: eventId } : '');\n fbq('track', event, customData, eventData);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tracking/MetaPixel.tsx","../../src/tracking/MetaPixelTracker.tsx","../../src/tracking/firePixelEvent.ts","../../src/tracking/PostHogProvider.tsx","../../src/tracking/KeystoneAnalyticsTracker.tsx","../../src/tracking/captureEvent.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 *\n * @param eventId - Optional server-side event ID for browser/server deduplication.\n * Pass the `eventId` returned by the form submission API so Meta can match and\n * deduplicate the browser Lead event against the server-side CAPI Lead event.\n * Format: fbq('track', event, params, { eventID: eventId })\n */\nexport function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): 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 const customData = Object.keys(normalized).length > 0 ? normalized : undefined;\n const eventData = eventId ? { eventID: eventId } : undefined;\n\n console.debug('[MetaPixel]', event, normalized, eventId ? { eventID: eventId } : '');\n fbq('track', event, customData, eventData);\n}\n","'use client';\n\nimport posthog from 'posthog-js';\nimport { PostHogProvider as PHProvider } from 'posthog-js/react';\nimport { useEffect, Suspense } from 'react';\nimport { usePathname, useSearchParams } from 'next/navigation';\n\nconst DEFAULT_HOST = 'https://us.i.posthog.com';\n\nexport type PostHogProviderProps = {\n apiKey: string;\n apiHost?: string;\n /** Keystone account ID — attached to every event as a super property. */\n accountId?: number;\n /** Keystone account name (company_name) — attached to every event as a super property. */\n accountName?: string;\n children: React.ReactNode;\n};\n\n// ---------------------------------------------------------------------------\n// Page name resolution\n// ---------------------------------------------------------------------------\n\ntype PageInfo = { page_name: string; page_slug?: string };\n\nfunction resolvePageInfo(pathname: string): PageInfo {\n if (pathname === '/') return { page_name: 'home' };\n\n const patterns: Array<[RegExp, (m: RegExpMatchArray) => PageInfo]> = [\n [/^\\/services\\/(.+)$/, ([, slug]) => ({ page_name: 'service_detail', page_slug: slug })],\n [/^\\/locations\\/(.+)$/, ([, slug]) => ({ page_name: 'location_detail', page_slug: slug })],\n [/^\\/blog\\/(.+)$/, ([, slug]) => ({ page_name: 'blog_post', page_slug: slug })],\n [/^\\/jobs\\/(.+)$/, ([, slug]) => ({ page_name: 'job_detail', page_slug: slug })],\n [/^\\/packages\\/(.+)$/, ([, slug]) => ({ page_name: 'package_detail', page_slug: slug })],\n ];\n\n for (const [pattern, resolve] of patterns) {\n const match = pathname.match(pattern);\n if (match) return resolve(match);\n }\n\n const staticNames: Record<string, string> = {\n '/services': 'services',\n '/locations': 'locations',\n '/contact': 'contact',\n '/about': 'about',\n '/blog': 'blog',\n '/portal': 'portal',\n '/gallery': 'gallery',\n '/team': 'team',\n '/faq': 'faq',\n '/reviews': 'reviews',\n '/jobs': 'jobs',\n '/packages': 'packages',\n '/service-menu': 'service_menu',\n '/privacy-policy': 'privacy_policy',\n '/terms': 'terms_of_service',\n };\n\n return { page_name: staticNames[pathname] ?? 'unknown' };\n}\n\n// ---------------------------------------------------------------------------\n// Pageview tracker — must be wrapped in <Suspense> (useSearchParams)\n// ---------------------------------------------------------------------------\n\nfunction PostHogPageviewTracker() {\n const pathname = usePathname();\n const searchParams = useSearchParams();\n\n useEffect(() => {\n if (!pathname) return;\n\n const search = searchParams?.toString();\n const url = window.location.origin + pathname + (search ? `?${search}` : '');\n const { page_name, page_slug } = resolvePageInfo(pathname);\n\n posthog.capture('$pageview', {\n $current_url: url,\n page_name,\n page_path: pathname,\n ...(page_slug && { page_slug }),\n });\n }, [pathname, searchParams]);\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Provider\n// ---------------------------------------------------------------------------\n\n/**\n * Initialises PostHog, registers account-level super properties, and fires\n * an enriched `$pageview` on every App Router navigation.\n *\n * Super properties attached to every event automatically:\n * - account_id (Keystone account ID)\n * - account_name (company_name)\n * - site_domain (window.location.hostname)\n *\n * Mount once in the root layout body. One project key covers all customer\n * sites — filter by account_name or site_domain in the PostHog dashboard.\n */\nexport function PostHogProvider({ apiKey, apiHost, accountId, accountName, children }: PostHogProviderProps) {\n useEffect(() => {\n posthog.init(apiKey, {\n api_host: apiHost ?? DEFAULT_HOST,\n person_profiles: 'identified_only',\n capture_pageview: false,\n });\n\n posthog.register({\n ...(accountId !== undefined && { account_id: accountId }),\n ...(accountName && { account_name: accountName }),\n site_domain: window.location.hostname,\n });\n }, [apiKey, apiHost, accountId, accountName]);\n\n return (\n <PHProvider client={posthog}>\n <Suspense fallback={null}>\n <PostHogPageviewTracker />\n </Suspense>\n {children}\n </PHProvider>\n );\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { usePathname } from 'next/navigation';\nimport { captureEvent } from './captureEvent';\n\ntype Props = {\n /** External booking / portal URL. When set, fires booking_cta_clicked on any click to that URL. */\n bookingUrl?: string | null;\n};\n\n/**\n * Page-level PostHog event tracker. Mount once inside PostHogProvider in\n * KeystoneRootLayout alongside MetaPixelTracker.\n *\n * Responsibilities:\n * - booking_cta_clicked: fires when any link to the booking URL is clicked,\n * capturing the page the visitor was on when they clicked.\n */\nexport function KeystoneAnalyticsTracker({ bookingUrl }: Props) {\n const pathname = usePathname();\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 captureEvent('booking_cta_clicked', {\n source_path: pathname,\n booking_url: bookingUrl,\n });\n }\n };\n\n document.addEventListener('click', handleClick);\n return () => document.removeEventListener('click', handleClick);\n }, [bookingUrl, pathname]);\n\n return null;\n}\n","/**\n * PostHog event capture — Keystone customer sites.\n *\n * ## Naming convention\n * All events use snake_case `object_action` format.\n * Properties use snake_case as well.\n *\n * ## Event taxonomy\n * Add new events here: define the name in `KsEventName` and its required\n * properties in `KsEventProperties`. Every callsite is then type-checked.\n *\n * ## Usage\n *\n * import { captureEvent } from 'keystone-design-bootstrap/tracking';\n *\n * captureEvent('form_submitted', { form_type: 'lead' });\n * captureEvent('booking_cta_clicked', { source_path: '/services/massage', booking_url: url });\n *\n * All calls are safe no-ops when PostHog has not been initialised (e.g. no\n * POSTHOG_API_KEY configured on the server).\n *\n * ## Super properties\n * account_id, account_name, and site_domain are registered as super properties\n * by PostHogProvider and are automatically attached to every event — you do not\n * need to include them in individual captureEvent calls.\n */\n\nimport posthog from 'posthog-js';\n\n// ---------------------------------------------------------------------------\n// Event taxonomy\n// ---------------------------------------------------------------------------\n\nexport type KsEventName =\n // Navigation\n | 'page_viewed'\n // Booking / conversion\n | 'booking_cta_clicked'\n // Forms\n | 'form_submitted'\n | 'form_failed'\n // Chat widget\n | 'chat_opened'\n | 'chat_message_sent'\n | 'chat_message_failed'\n // Member portal — tab navigation\n | 'portal_tab_viewed'\n // Member portal — authentication flow\n | 'portal_login_started'\n | 'portal_login_identified'\n | 'portal_login_completed'\n | 'portal_login_failed';\n\nexport type KsEventProperties = {\n /** Fired on every page navigation. $pageview is also fired for PostHog web analytics. */\n page_viewed: {\n page_name: string;\n page_path: string;\n page_slug?: string;\n };\n\n /** Fired when a visitor clicks any CTA that links to the external booking URL. */\n booking_cta_clicked: {\n source_path: string;\n booking_url: string;\n };\n\n /** Fired when a Keystone form is successfully submitted. */\n form_submitted: {\n /** One of: lead | job_application | marketing_list_signup */\n form_type: string;\n /** Server-generated event ID for CAPI deduplication (when present). */\n event_id?: string;\n };\n\n /** Fired when a form submission fails (validation error or network error). */\n form_failed: {\n form_type: string;\n error: string;\n };\n\n /** Fired when the chat widget is first opened by the visitor. */\n chat_opened: Record<string, never>;\n\n /** Fired when a chat message is successfully sent. */\n chat_message_sent: {\n /** Whether the visitor is authenticated (contactId present). */\n is_authenticated: boolean;\n };\n\n /** Fired when a chat message fails to send. */\n chat_message_failed: {\n error: string;\n };\n\n /** Fired when a member portal tab is opened. */\n portal_tab_viewed: {\n tab: string;\n };\n\n /** Fired when the portal login modal is opened / login flow starts. */\n portal_login_started: Record<string, never>;\n\n /**\n * Fired after the identifier step resolves — we know whether the user\n * already has an account.\n */\n portal_login_identified: {\n method: 'email' | 'phone' | 'email_and_phone';\n user_exists: boolean;\n };\n\n /** Fired after the user successfully signs in or creates an account. */\n portal_login_completed: {\n flow: 'signin' | 'signup';\n };\n\n /** Fired when any step of the login flow returns an error. */\n portal_login_failed: {\n step: 'identifier' | 'signin' | 'signup';\n reason: string;\n };\n};\n\n// ---------------------------------------------------------------------------\n// Capture helper\n// ---------------------------------------------------------------------------\n\n/**\n * Captures a typed Keystone analytics event via PostHog.\n * Safe no-op when PostHog has not been initialised.\n */\nexport function captureEvent<E extends KsEventName>(\n event: E,\n ...args: KsEventProperties[E] extends Record<string, never>\n ? []\n : [properties: KsEventProperties[E]]\n): void {\n posthog.capture(event, args[0] as Record<string, unknown>);\n}\n"],"mappings":"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","names":["useEffect","usePathname","usePathname","useEffect","useEffect","usePathname","posthog","usePathname","useEffect"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.69",
3
+ "version": "1.0.71",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -57,7 +57,12 @@ export function HeaderNavigation({
57
57
 
58
58
  // Use navigation from config or override
59
59
  const navigation = navigationOverride || config?.navigation?.header || [];
60
- const ctaUrls = resolveCtaUrls(companyInformation);
60
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
61
+ const ctaUrls = {
62
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
63
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
64
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
65
+ };
61
66
 
62
67
  // Hide the sticky bottom bar when the user is already on the portal page —
63
68
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -36,7 +36,12 @@ export function HeaderNavigation({
36
36
  const companyName = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
37
37
 
38
38
  const navigation = navigationOverride || config?.navigation?.header || [];
39
- const ctaUrls = resolveCtaUrls(companyInformation);
39
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
40
+ const ctaUrls = {
41
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
42
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
43
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
44
+ };
40
45
 
41
46
  // Hide the sticky bottom bar when the user is already on the portal page —
42
47
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -42,7 +42,12 @@ export function HeaderNavigation({
42
42
 
43
43
  // Use navigation from config or override
44
44
  const navigation = navigationOverride || config?.navigation?.header || [];
45
- const ctaUrls = resolveCtaUrls(companyInformation);
45
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
46
+ const ctaUrls = {
47
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
48
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
49
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
50
+ };
46
51
 
47
52
  // Hide the sticky bottom bar when the user is already on the portal page —
48
53
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -99,7 +99,12 @@ export function HeaderNavigation({
99
99
  const logoImage = logoImageOverride || getLogoUrl(websitePhotos) || props?.logo?.image;
100
100
  const logoText = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
101
101
  const cta_button = props?.cta_button;
102
- const ctaUrls = resolveCtaUrls(companyInformation);
102
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
103
+ const ctaUrls = {
104
+ primaryHref: cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
105
+ secondaryHref: cta_button?.href ?? resolvedCtaUrls.secondaryHref,
106
+ hasSecondary: cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
107
+ };
103
108
 
104
109
  const logo = {
105
110
  text: logoText || '',
@@ -81,6 +81,12 @@ export function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | un
81
81
  export type AnalyticsConfig = {
82
82
  /** PostHog project API key — platform-wide, from POSTHOG_API_KEY env var on the Rails server. */
83
83
  posthog_api_key?: string;
84
+ /**
85
+ * Environment identifier from KEYSTONE_ENV on the Rails server (e.g. "production", "staging",
86
+ * "development"). Registered as a PostHog super property on every event so the sync job can
87
+ * filter by environment and avoid cross-environment data contamination.
88
+ */
89
+ environment?: string;
84
90
  };
85
91
 
86
92
  /** Analytics config for customer sites. Returns platform-wide analytics keys (e.g. PostHog). */
@@ -96,6 +102,11 @@ export function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undef
96
102
  return str !== '' && str !== 'null' ? str : null;
97
103
  }
98
104
 
105
+ /** Extract environment string from analytics config for use with <PostHogProvider environment={...} />. */
106
+ export function getKeystoneEnvironment(analyticsConfig: AnalyticsConfig | null | undefined): string {
107
+ return analyticsConfig?.environment?.trim() || 'development';
108
+ }
109
+
99
110
  export async function getServices(): Promise<Service[] | null> {
100
111
  return serverFetch<Service[]>('/public/services', defaultOptions);
101
112
  }
@@ -18,6 +18,7 @@ import {
18
18
  getMetaPixelId,
19
19
  getAnalyticsConfig,
20
20
  getPostHogApiKey,
21
+ getKeystoneEnvironment,
21
22
  } from '../../lib/server-api';
22
23
 
23
24
  import type { CompanyInformation, Location, NavItem, Service, SiteConfig } from '../../types';
@@ -28,8 +29,18 @@ export type KeystoneRootLayoutHeaderOverrides = {
28
29
  logoText?: string;
29
30
  /** Overrides the primary CTA button label (default: "Contact Us") */
30
31
  ctaLabel?: string;
32
+ /**
33
+ * Overrides the href for the left ("Contact Us") header button.
34
+ * Defaults to `/contact`. Only relevant when two CTA buttons are shown.
35
+ */
36
+ ctaHref?: string;
31
37
  /** Overrides the secondary CTA button label (default: "Book Now" when a booking/portal URL is configured) */
32
38
  secondaryLabel?: string;
39
+ /**
40
+ * Overrides the href for the right ("Book Now") header button.
41
+ * When set, forces both CTA buttons to appear even if no external booking/portal URL is configured.
42
+ */
43
+ secondaryHref?: string;
33
44
  };
34
45
 
35
46
  export type KeystoneRootLayoutOptions = {
@@ -139,6 +150,7 @@ export async function KeystoneRootLayout(props: {
139
150
 
140
151
  const metaPixelId = getMetaPixelId(adsConfig);
141
152
  const posthogApiKey = getPostHogApiKey(analyticsConfig);
153
+ const keystoneEnvironment = getKeystoneEnvironment(analyticsConfig);
142
154
 
143
155
  const services = Array.isArray(servicesData) ? servicesData : [];
144
156
  const locations = Array.isArray(locationsData) ? locationsData : [];
@@ -165,7 +177,9 @@ export async function KeystoneRootLayout(props: {
165
177
  },
166
178
  cta_button: {
167
179
  label: headerOverrides?.ctaLabel || 'Contact Us',
180
+ ...(headerOverrides?.ctaHref != null && { href: headerOverrides.ctaHref }),
168
181
  secondary_label: headerOverrides?.secondaryLabel ?? (bookingHref ? 'Book Now' : undefined),
182
+ ...(headerOverrides?.secondaryHref != null && { secondary_href: headerOverrides.secondaryHref }),
169
183
  },
170
184
  };
171
185
 
@@ -214,6 +228,7 @@ export async function KeystoneRootLayout(props: {
214
228
  apiHost={posthogHost}
215
229
  accountId={accountId}
216
230
  accountName={accountName}
231
+ environment={keystoneEnvironment}
217
232
  >
218
233
  <KeystoneAnalyticsTracker bookingUrl={bookingHref} />
219
234
  {bodyContent}