keystone-design-bootstrap 1.0.75 → 1.0.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.75",
3
+ "version": "1.0.77",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,10 +18,6 @@ const inputClass =
18
18
  'block w-full rounded-input border border-primary bg-primary px-3 py-2.5 text-base text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors';
19
19
  const labelClass = 'block text-sm text-secondary mb-1';
20
20
 
21
- function isValidEmail(value: string): boolean {
22
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
23
- }
24
-
25
21
  export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
26
22
  const router = useRouter();
27
23
  const [step, setStep] = useState<Step>('identifier');
@@ -31,7 +27,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
31
27
  }, []);
32
28
 
33
29
  // Identifier step state
34
- const [email, setEmail] = useState('');
35
30
  const [phoneValue, setPhoneValue] = useState(''); // formatted national number
36
31
  const [selectedCountry, setSelectedCountry] = useState('US');
37
32
 
@@ -64,7 +59,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
64
59
  // Computed API values
65
60
  const phoneDigits = phoneValue.replace(/\D/g, '');
66
61
  const fullPhone = phoneDigits.length > 0 ? `${phoneCode}${phoneDigits}` : null;
67
- const emailVal = email.trim() || null;
62
+ const emailVal = null;
68
63
 
69
64
  const emailInput = (value: string, onChange: (v: string) => void, required = false) => (
70
65
  <input
@@ -107,8 +102,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
107
102
 
108
103
  const handleContinue = async (e: React.FormEvent) => {
109
104
  e.preventDefault();
110
- if (!emailVal && !fullPhone) { setError('Enter your email address or phone number to continue.'); return; }
111
- if (emailVal && !isValidEmail(emailVal)) { setError('Enter a valid email address.'); return; }
105
+ if (!fullPhone) { setError('Enter your phone number to continue.'); return; }
112
106
  setError(null);
113
107
  setLoading(true);
114
108
  try {
@@ -118,14 +112,14 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
118
112
  body: JSON.stringify({ email: emailVal, phone: fullPhone }),
119
113
  });
120
114
  const result = await res.json().catch(() => ({ exists: null }));
121
- const method = emailVal && fullPhone ? 'email_and_phone' : emailVal ? 'email' : 'phone';
115
+ const method = 'phone';
122
116
  if (result.exists === true) {
123
117
  await setPixelUserData({ email: emailVal, phone: fullPhone });
124
118
  firePixelEvent('Lead');
125
119
  captureEvent('portal_login_identified', { method, user_exists: true });
126
120
  setWelcomeName(result.firstName ?? null);
127
121
  if (result.hasPassword === false) {
128
- setIdentifiedWith(emailVal ? 'email' : 'phone');
122
+ setIdentifiedWith('phone');
129
123
  setStep('new');
130
124
  } else {
131
125
  setStep('returning');
@@ -134,7 +128,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
134
128
  await setPixelUserData({ email: emailVal, phone: fullPhone });
135
129
  firePixelEvent('Lead');
136
130
  captureEvent('portal_login_identified', { method, user_exists: false });
137
- setIdentifiedWith(emailVal ? 'email' : 'phone');
131
+ setIdentifiedWith('phone');
138
132
  setStep('new');
139
133
  } else {
140
134
  captureEvent('portal_login_failed', { step: 'identifier', reason: 'unknown_error' });
@@ -229,7 +223,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
229
223
  setError(null);
230
224
  };
231
225
 
232
- const identifierSummary = [emailVal, fullPhone].filter(Boolean).join(' / ');
226
+ const identifierSummary = fullPhone ?? '';
233
227
 
234
228
  const headerTitle =
235
229
  step === 'identifier' ? 'Pricing & Booking Portal' :
@@ -237,7 +231,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
237
231
  (welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome!');
238
232
 
239
233
  const headerSubtitle =
240
- step === 'identifier' ? 'Enter your email or phone number to get started.' :
234
+ step === 'identifier' ? 'Enter your phone number to get started.' :
241
235
  step === 'returning' ? 'Enter your password to sign in.' :
242
236
  (welcomeName ? 'Create a password to access your account.' : "Let's get you set up.");
243
237
 
@@ -272,18 +266,9 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
272
266
  {/* Step: identifier */}
273
267
  {step === 'identifier' && (
274
268
  <form onSubmit={handleContinue} className="space-y-3">
275
- <div>
276
- <label className={labelClass}>Email address</label>
277
- {emailInput(email, setEmail)}
278
- </div>
279
- <div className="flex items-center gap-3">
280
- <hr className="flex-1 border-secondary" />
281
- <span className="text-xs text-quaternary">or</span>
282
- <hr className="flex-1 border-secondary" />
283
- </div>
284
269
  <div>
285
270
  <label className={labelClass}>Phone number</label>
286
- {phoneInput(phoneValue, setPhoneValue)}
271
+ {phoneInput(phoneValue, setPhoneValue, true)}
287
272
  </div>
288
273
  <div className="pt-1">
289
274
  <button
@@ -326,7 +311,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
326
311
  onClick={goBack}
327
312
  className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
328
313
  >
329
- ← {identifierSummary ? `Not ${identifierSummary}?` : 'Change email or phone'}
314
+ ← {identifierSummary ? `Not ${identifierSummary}?` : 'Change phone number'}
330
315
  </button>
331
316
  </form>
332
317
  )}
@@ -81,6 +81,8 @@ 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
+ /** Per-site GTM container ID (e.g. GTM-ABC123), provisioned by Keystone. */
85
+ gtm_container_public_id?: string;
84
86
  /**
85
87
  * Environment identifier from KEYSTONE_ENV on the Rails server (e.g. "production", "staging",
86
88
  * "development"). Registered as a PostHog super property on every event so the sync job can
@@ -102,6 +104,13 @@ export function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undef
102
104
  return str !== '' && str !== 'null' ? str : null;
103
105
  }
104
106
 
107
+ /** Extract GTM container ID from analytics config for use with <GoogleTagManager containerId={...} />. */
108
+ export function getGtmContainerPublicId(analyticsConfig: AnalyticsConfig | null | undefined): string | null {
109
+ const value = analyticsConfig?.gtm_container_public_id;
110
+ const str = value != null && value !== '' ? String(value).trim() : '';
111
+ return str !== '' && str !== 'null' && /^GTM-[A-Z0-9]+$/i.test(str) ? str : null;
112
+ }
113
+
105
114
  /** Extract environment string from analytics config for use with <PostHogProvider environment={...} />. */
106
115
  export function getKeystoneEnvironment(analyticsConfig: AnalyticsConfig | null | undefined): string {
107
116
  return analyticsConfig?.environment?.trim() || 'development';
@@ -3,7 +3,13 @@ import type { Metadata } from 'next';
3
3
 
4
4
  import { HeaderNavigation, FooterHome } from '../../design_system/sections';
5
5
  import { ThemeProvider } from '../../contexts';
6
- import { MetaPixel, MetaPixelTracker, PostHogProvider, KeystoneAnalyticsTracker } from '../../tracking';
6
+ import {
7
+ MetaPixel,
8
+ MetaPixelTracker,
9
+ PostHogProvider,
10
+ KeystoneAnalyticsTracker,
11
+ GoogleTagManager,
12
+ } from '../../tracking';
7
13
  import { ChatWidget } from '../../design_system/components/ChatWidget';
8
14
  import { FormDefinitionsProvider } from '../contexts/form-definitions';
9
15
  import { KeystoneSSRProvider } from '../providers/ssr-provider';
@@ -18,6 +24,7 @@ import {
18
24
  getMetaPixelId,
19
25
  getAnalyticsConfig,
20
26
  getPostHogApiKey,
27
+ getGtmContainerPublicId,
21
28
  getKeystoneEnvironment,
22
29
  } from '../../lib/server-api';
23
30
 
@@ -111,7 +118,7 @@ function buildNavigationWithDynamicData(
111
118
  * Note: Next's `export const metadata` must remain in the site/app.
112
119
  * This component focuses on:
113
120
  * - fetching shared data
114
- * - injecting Meta Pixel (from ads_config) and PostHog (from analytics_config)
121
+ * - injecting Meta Pixel (from ads_config), GTM, and PostHog (from analytics_config)
115
122
  * - applying theme
116
123
  * - rendering Header + Footer
117
124
  * - rendering ChatWidget gated by `companyInformation.chat_enabled`
@@ -150,6 +157,7 @@ export async function KeystoneRootLayout(props: {
150
157
 
151
158
  const metaPixelId = getMetaPixelId(adsConfig);
152
159
  const posthogApiKey = getPostHogApiKey(analyticsConfig);
160
+ const gtmContainerId = getGtmContainerPublicId(analyticsConfig);
153
161
  const keystoneEnvironment = getKeystoneEnvironment(analyticsConfig);
154
162
 
155
163
  const services = Array.isArray(servicesData) ? servicesData : [];
@@ -187,6 +195,7 @@ export async function KeystoneRootLayout(props: {
187
195
  <>
188
196
  {metaPixelId ? <MetaPixel pixelId={metaPixelId} /> : null}
189
197
  {metaPixelId ? <MetaPixelTracker bookingUrl={externalManagementUrl} /> : null}
198
+ {gtmContainerId ? <GoogleTagManager containerId={gtmContainerId} /> : null}
190
199
  <ThemeProvider theme={theme}>
191
200
  <KeystoneSSRProvider>
192
201
  <FormDefinitionsProvider
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import Script from 'next/script';
4
+
5
+ const GTM_SCRIPT = (containerId: string) => `
6
+ (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
7
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
8
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
9
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
10
+ })(window,document,'script','dataLayer','${containerId.replace(/'/g, "\\'")}');
11
+ `;
12
+
13
+ export type GoogleTagManagerProps = {
14
+ /** GTM container public ID (e.g. GTM-ABC123). */
15
+ containerId: string | null | undefined;
16
+ };
17
+
18
+ /**
19
+ * Renders Google Tag Manager script + noscript fallback.
20
+ * Mount once at root layout level when a container is provisioned.
21
+ */
22
+ export function GoogleTagManager({ containerId }: GoogleTagManagerProps) {
23
+ const raw = typeof containerId === 'string' ? containerId.trim() : '';
24
+ const id = raw && raw !== 'null' && /^GTM-[A-Z0-9]+$/i.test(raw) ? raw : '';
25
+
26
+ if (!id) {
27
+ return null;
28
+ }
29
+
30
+ return (
31
+ <>
32
+ <Script
33
+ id="google-tag-manager"
34
+ strategy="afterInteractive"
35
+ dangerouslySetInnerHTML={{ __html: GTM_SCRIPT(id) }}
36
+ />
37
+ <noscript>
38
+ <iframe
39
+ src={`https://www.googletagmanager.com/ns.html?id=${id}`}
40
+ height="0"
41
+ width="0"
42
+ style={{ display: 'none', visibility: 'hidden' }}
43
+ title="google-tag-manager"
44
+ />
45
+ </noscript>
46
+ </>
47
+ );
48
+ }
@@ -26,5 +26,7 @@ export type { PixelEvent, PixelEventParams, PixelUserData } from './firePixelEve
26
26
  export { PostHogProvider } from './PostHogProvider';
27
27
  export type { PostHogProviderProps } from './PostHogProvider';
28
28
  export { KeystoneAnalyticsTracker } from './KeystoneAnalyticsTracker';
29
+ export { GoogleTagManager } from './GoogleTagManager';
30
+ export type { GoogleTagManagerProps } from './GoogleTagManager';
29
31
  export { captureEvent } from './captureEvent';
30
32
  export type { KsEventName, KsEventProperties } from './captureEvent';
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Generates a beautiful gradient SVG data URL using the full color palette
3
- * Based on gradients used in corporate marketing site animations
4
3
  * Each seed (post ID or index) will consistently return the same gradient
5
4
  */
6
5
  export const getGradientUrl = (seed: string | number): string => {
7
- // Beautiful gradient combinations using the full palette from corporate marketing site
8
6
  // Colors match Tailwind's default palette (purple, pink, blue, green, orange, indigo)
9
7
  const gradients = [
10
8
  // Purple gradients (like from-purple-200 to-purple-300)