keystone-design-bootstrap 1.0.55 → 1.0.56

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.
Files changed (56) hide show
  1. package/dist/design_system/elements/index.js +8 -3
  2. package/dist/design_system/elements/index.js.map +1 -1
  3. package/dist/design_system/sections/index.js +203 -106
  4. package/dist/design_system/sections/index.js.map +1 -1
  5. package/dist/index.js +303 -247
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/hooks/index.js +72 -0
  8. package/dist/lib/hooks/index.js.map +1 -1
  9. package/dist/lib/server-api.js.map +1 -1
  10. package/dist/utils/phone-helpers.js +26 -0
  11. package/dist/utils/phone-helpers.js.map +1 -0
  12. package/package.json +5 -2
  13. package/src/design_system/components/ChatWidget.tsx +51 -34
  14. package/src/design_system/components/DynamicFormFields.tsx +1 -24
  15. package/src/design_system/elements/modal/modal.tsx +54 -35
  16. package/src/design_system/portal/LoginForm.tsx +339 -0
  17. package/src/design_system/portal/LoginModalController.tsx +63 -0
  18. package/src/design_system/portal/LogoutButton.tsx +23 -0
  19. package/src/design_system/portal/MessageComposer.tsx +84 -0
  20. package/src/design_system/portal/PortalPage.tsx +754 -0
  21. package/src/design_system/portal/RowThumbnail.tsx +76 -0
  22. package/src/design_system/portal/actions.ts +160 -0
  23. package/src/design_system/portal/index.ts +5 -0
  24. package/src/design_system/sections/index.tsx +1 -1
  25. package/src/design_system/sections/service-menu-section.tsx +7 -108
  26. package/src/lib/actions.ts +51 -115
  27. package/src/lib/consumer-session.ts +74 -0
  28. package/src/lib/hooks/index.ts +2 -0
  29. package/src/lib/hooks/use-image-cycle.ts +105 -0
  30. package/src/lib/server-api.ts +7 -6
  31. package/src/next/routes/chat.ts +30 -58
  32. package/src/next/routes/consumer-auth.ts +113 -0
  33. package/src/types/api/consumer.ts +39 -0
  34. package/src/types/api/offer.ts +1 -1
  35. package/src/types/api/package.ts +20 -0
  36. package/src/types/api/service.ts +6 -24
  37. package/src/types/index.ts +2 -0
  38. package/src/utils/phone-helpers.ts +27 -0
  39. package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
  40. package/dist/contexts/index.d.ts +0 -13
  41. package/dist/design_system/elements/index.d.ts +0 -372
  42. package/dist/design_system/logo/keystone-logo.d.ts +0 -6
  43. package/dist/design_system/sections/index.d.ts +0 -237
  44. package/dist/form-CpsCONG5.d.ts +0 -151
  45. package/dist/index.d.ts +0 -76
  46. package/dist/lib/component-registry.d.ts +0 -13
  47. package/dist/lib/hooks/index.d.ts +0 -64
  48. package/dist/lib/server-api.d.ts +0 -43
  49. package/dist/themes/index.d.ts +0 -16
  50. package/dist/types/index.d.ts +0 -264
  51. package/dist/utils/cx.d.ts +0 -15
  52. package/dist/utils/gradient-placeholder.d.ts +0 -8
  53. package/dist/utils/is-react-component.d.ts +0 -21
  54. package/dist/utils/markdown-toc.d.ts +0 -14
  55. package/dist/utils/photo-helpers.d.ts +0 -37
  56. package/dist/website-photos-Bm-CBK9g.d.ts +0 -47
@@ -0,0 +1,339 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useMemo } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { loginAction, signupAction, initiateAuthAction } from './actions';
6
+ import { countries } from '../../utils/countries';
7
+ import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
8
+
9
+ type Step = 'identifier' | 'returning' | 'new';
10
+
11
+ interface LoginFormProps {
12
+ onSuccess?: () => void;
13
+ onClose?: () => void;
14
+ }
15
+
16
+ const inputClass =
17
+ 'block w-full rounded border border-gray-300 bg-primary px-3 py-2.5 text-sm text-primary placeholder-gray-400 focus:border-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-700 transition-colors';
18
+ const labelClass = 'block text-sm text-secondary mb-1';
19
+
20
+ function isValidEmail(value: string): boolean {
21
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
22
+ }
23
+
24
+ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
25
+ const router = useRouter();
26
+ const [step, setStep] = useState<Step>('identifier');
27
+
28
+ // Identifier step state
29
+ const [email, setEmail] = useState('');
30
+ const [phoneValue, setPhoneValue] = useState(''); // formatted national number
31
+ const [selectedCountry, setSelectedCountry] = useState('US');
32
+
33
+ // Later steps
34
+ const [welcomeName, setWelcomeName] = useState<string | null>(null);
35
+ const [firstName, setFirstName] = useState('');
36
+ const [lastName, setLastName] = useState('');
37
+ const [password, setPassword] = useState('');
38
+ const [passwordConfirm, setPasswordConfirm] = useState('');
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [loading, setLoading] = useState(false);
41
+
42
+ // Phone helpers
43
+ const country = useMemo(() => countries.find((c) => c.code === selectedCountry), [selectedCountry]);
44
+ const phoneCode = country ? (country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`) : '+1';
45
+ const nationalMask = useMemo(() => getNationalMask(country), [country]);
46
+ const nationalPlaceholder = nationalMask ? nationalMask.replace(/#/g, '0') : '(555) 000-0000';
47
+ const countryOptions = useMemo(
48
+ () => countries.map((c) => ({ label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`, value: c.code })),
49
+ []
50
+ );
51
+
52
+ // Computed API values
53
+ const phoneDigits = phoneValue.replace(/\D/g, '');
54
+ const fullPhone = phoneDigits.length > 0 ? `${phoneCode}${phoneDigits}` : null;
55
+ const emailVal = email.trim() || null;
56
+
57
+ const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
58
+ const digits = e.target.value.replace(/\D/g, '');
59
+ const formatted = nationalMask ? formatDigitsToMask(digits, nationalMask) : digits;
60
+ setPhoneValue(formatted);
61
+ };
62
+
63
+ const handleContinue = async (e: React.FormEvent) => {
64
+ e.preventDefault();
65
+ if (!emailVal && !fullPhone) { setError('Enter your email address or phone number to continue.'); return; }
66
+ if (emailVal && !isValidEmail(emailVal)) { setError('Enter a valid email address.'); return; }
67
+ setError(null);
68
+ setLoading(true);
69
+ try {
70
+ const result = await initiateAuthAction({ email: emailVal, phone: fullPhone });
71
+ if (result.exists === true) {
72
+ setWelcomeName(result.firstName ?? null);
73
+ setStep(result.hasPassword === false ? 'new' : 'returning');
74
+ } else if (result.exists === false) {
75
+ setStep('new');
76
+ } else {
77
+ // exists === null — network/server error. Stay on identifier step with a message.
78
+ setError('Something went wrong. Please check your connection and try again.');
79
+ }
80
+ } finally {
81
+ setLoading(false);
82
+ }
83
+ };
84
+
85
+ const handleSignIn = async (e: React.FormEvent) => {
86
+ e.preventDefault();
87
+ setError(null);
88
+ setLoading(true);
89
+ try {
90
+ const result = await loginAction({ email: emailVal, phone: fullPhone, password });
91
+ if (result.error) {
92
+ if (result.code === 'not_found' || result.code === 'no_password') {
93
+ setStep('new');
94
+ setError(null);
95
+ return;
96
+ }
97
+ setError(result.error);
98
+ return;
99
+ }
100
+ if (onSuccess) onSuccess(); else router.refresh();
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ };
105
+
106
+ const handleSignUp = async (e: React.FormEvent) => {
107
+ e.preventDefault();
108
+ if (password !== passwordConfirm) { setError('Passwords do not match.'); return; }
109
+ setError(null);
110
+ setLoading(true);
111
+ try {
112
+ const result = await signupAction({
113
+ email: emailVal,
114
+ phone: fullPhone,
115
+ password,
116
+ password_confirmation: passwordConfirm,
117
+ first_name: firstName.trim() || undefined,
118
+ last_name: lastName.trim() || undefined,
119
+ });
120
+ if (result.error) { setError(result.error); return; }
121
+ if (onSuccess) onSuccess(); else router.refresh();
122
+ } finally {
123
+ setLoading(false);
124
+ }
125
+ };
126
+
127
+ const goBack = () => {
128
+ setStep('identifier');
129
+ setPassword('');
130
+ setPasswordConfirm('');
131
+ setError(null);
132
+ };
133
+
134
+ const identifierSummary = [emailVal, fullPhone].filter(Boolean).join(' / ');
135
+
136
+ const headerTitle =
137
+ step === 'identifier' ? 'Pricing & Booking Portal' :
138
+ step === 'returning' ? (welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome back!') :
139
+ (welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome!');
140
+
141
+ const headerSubtitle =
142
+ step === 'identifier' ? 'Enter your email or phone number to get started.' :
143
+ step === 'returning' ? 'Enter your password to sign in.' :
144
+ (welcomeName ? 'Create a password to access your account.' : "Let's get you set up.");
145
+
146
+ return (
147
+ <div>
148
+ {/* Header */}
149
+ <div className="flex items-start justify-between gap-4 mb-5">
150
+ <div>
151
+ <h3 className="text-lg font-normal text-primary">{headerTitle}</h3>
152
+ <p className="mt-0.5 text-sm text-tertiary">{headerSubtitle}</p>
153
+ </div>
154
+ {onClose && (
155
+ <button
156
+ type="button"
157
+ onClick={onClose}
158
+ className="shrink-0 p-1 text-tertiary hover:text-primary transition-colors"
159
+ aria-label="Close"
160
+ >
161
+ <svg className="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
162
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
163
+ </svg>
164
+ </button>
165
+ )}
166
+ </div>
167
+
168
+ {error && (
169
+ <div className="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
170
+ {error}
171
+ </div>
172
+ )}
173
+
174
+ {/* Step: identifier */}
175
+ {step === 'identifier' && (
176
+ <form onSubmit={handleContinue} className="space-y-3">
177
+ <div>
178
+ <label className={labelClass}>Email address</label>
179
+ <input
180
+ type="email"
181
+ value={email}
182
+ onChange={(e) => setEmail(e.target.value)}
183
+ placeholder="you@example.com"
184
+ className={inputClass}
185
+ autoComplete="email"
186
+ autoFocus
187
+ />
188
+ </div>
189
+ <div className="flex items-center gap-3">
190
+ <hr className="flex-1 border-gray-200" />
191
+ <span className="text-xs text-gray-400">or</span>
192
+ <hr className="flex-1 border-gray-200" />
193
+ </div>
194
+ <div>
195
+ <label className={labelClass}>Phone number</label>
196
+ <div className="flex rounded border border-gray-300 overflow-hidden focus-within:border-gray-700 focus-within:ring-1 focus-within:ring-gray-700 transition-colors">
197
+ <select
198
+ value={selectedCountry}
199
+ onChange={(e) => { setSelectedCountry(e.target.value); setPhoneValue(''); }}
200
+ className="border-r border-gray-200 bg-gray-50 px-2 py-2.5 text-sm text-gray-700 focus:outline-none"
201
+ aria-label="Country code"
202
+ >
203
+ {countryOptions.map((opt) => (
204
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
205
+ ))}
206
+ </select>
207
+ <input
208
+ type="tel"
209
+ value={phoneValue}
210
+ onChange={handlePhoneChange}
211
+ placeholder={nationalPlaceholder}
212
+ className="flex-1 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 bg-transparent focus:outline-none"
213
+ />
214
+ </div>
215
+ </div>
216
+ <div className="pt-1">
217
+ <button
218
+ type="submit"
219
+ disabled={loading}
220
+ className="w-full rounded border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
221
+ >
222
+ {loading ? 'Looking you up…' : 'Continue'}
223
+ </button>
224
+ </div>
225
+ </form>
226
+ )}
227
+
228
+ {/* Step: returning user */}
229
+ {step === 'returning' && (
230
+ <form onSubmit={handleSignIn} className="space-y-3">
231
+ <div>
232
+ <label className={labelClass}>Password</label>
233
+ <input
234
+ type="password"
235
+ value={password}
236
+ onChange={(e) => setPassword(e.target.value)}
237
+ placeholder="••••••••"
238
+ className={inputClass}
239
+ required
240
+ autoComplete="current-password"
241
+ autoFocus
242
+ />
243
+ </div>
244
+ <div className="pt-1">
245
+ <button
246
+ type="submit"
247
+ disabled={loading}
248
+ className="w-full rounded border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
249
+ >
250
+ {loading ? 'Signing in…' : 'Sign in'}
251
+ </button>
252
+ </div>
253
+ <button
254
+ type="button"
255
+ onClick={goBack}
256
+ className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
257
+ >
258
+ ← {identifierSummary ? `Not ${identifierSummary}?` : 'Change email or phone'}
259
+ </button>
260
+ </form>
261
+ )}
262
+
263
+ {/* Step: new user / claim account */}
264
+ {step === 'new' && (
265
+ <form onSubmit={handleSignUp} className="space-y-3">
266
+ {/* Only ask for name if we don't already know who they are */}
267
+ {!welcomeName && (
268
+ <div className="grid grid-cols-2 gap-3">
269
+ <div>
270
+ <label className={labelClass}>First name</label>
271
+ <input
272
+ type="text"
273
+ value={firstName}
274
+ onChange={(e) => setFirstName(e.target.value)}
275
+ placeholder="Jane"
276
+ className={inputClass}
277
+ autoComplete="given-name"
278
+ autoFocus
279
+ />
280
+ </div>
281
+ <div>
282
+ <label className={labelClass}>Last name</label>
283
+ <input
284
+ type="text"
285
+ value={lastName}
286
+ onChange={(e) => setLastName(e.target.value)}
287
+ placeholder="Smith"
288
+ className={inputClass}
289
+ autoComplete="family-name"
290
+ />
291
+ </div>
292
+ </div>
293
+ )}
294
+ <div>
295
+ <label className={labelClass}>Password</label>
296
+ <input
297
+ type="password"
298
+ value={password}
299
+ onChange={(e) => setPassword(e.target.value)}
300
+ placeholder="••••••••"
301
+ className={inputClass}
302
+ required
303
+ autoComplete="new-password"
304
+ autoFocus={!!welcomeName}
305
+ />
306
+ </div>
307
+ <div>
308
+ <label className={labelClass}>Confirm password</label>
309
+ <input
310
+ type="password"
311
+ value={passwordConfirm}
312
+ onChange={(e) => setPasswordConfirm(e.target.value)}
313
+ placeholder="••••••••"
314
+ className={inputClass}
315
+ required
316
+ autoComplete="new-password"
317
+ />
318
+ </div>
319
+ <div className="pt-1">
320
+ <button
321
+ type="submit"
322
+ disabled={loading}
323
+ className="w-full rounded border border-secondary bg-primary px-4 py-2.5 text-sm font-medium text-primary hover:bg-secondary transition-colors disabled:opacity-50"
324
+ >
325
+ {loading ? 'Creating account…' : 'Create account'}
326
+ </button>
327
+ </div>
328
+ <button
329
+ type="button"
330
+ onClick={goBack}
331
+ className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
332
+ >
333
+ ← Back
334
+ </button>
335
+ </form>
336
+ )}
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Modal } from '../elements/modal/modal';
6
+ import { LoginForm } from './LoginForm';
7
+ import { KeystoneLogoMinimal } from '../logo/keystone-logo-minimal';
8
+
9
+ const keystoneFooter = (
10
+ <div className="px-6 py-4 bg-secondary flex flex-col items-center gap-1">
11
+ <div className="flex items-center gap-1.5">
12
+ <KeystoneLogoMinimal className="size-5 shrink-0" />
13
+ <span className="text-sm font-medium text-primary">Keystone</span>
14
+ </div>
15
+ <p className="text-xs text-quaternary">Powered by Keystone Universal Login</p>
16
+ </div>
17
+ );
18
+
19
+ /**
20
+ * Renders a single login modal for the entire portal.
21
+ * Any server-rendered element with `data-open-login-modal` will open it on click.
22
+ * After successful auth the server component re-renders with the new cookie.
23
+ */
24
+ export function LoginModalController() {
25
+ const [open, setOpen] = useState(false);
26
+ const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
27
+ const router = useRouter();
28
+
29
+ useEffect(() => {
30
+ const handleClick = (e: MouseEvent) => {
31
+ const trigger = (e.target as HTMLElement).closest('[data-open-login-modal]');
32
+ if (trigger) {
33
+ e.preventDefault();
34
+ setRedirectUrl(trigger.getAttribute('data-login-redirect'));
35
+ setOpen(true);
36
+ }
37
+ };
38
+ document.addEventListener('click', handleClick);
39
+ return () => document.removeEventListener('click', handleClick);
40
+ }, []);
41
+
42
+ const handleSuccess = () => {
43
+ setOpen(false);
44
+ if (redirectUrl) {
45
+ router.push(redirectUrl);
46
+ setRedirectUrl(null);
47
+ } else {
48
+ router.refresh();
49
+ }
50
+ };
51
+
52
+ return (
53
+ <Modal
54
+ isOpen={open}
55
+ onClose={() => setOpen(false)}
56
+ hideHeader
57
+ ariaLabel="Sign in or create account"
58
+ footer={keystoneFooter}
59
+ >
60
+ <LoginForm onSuccess={handleSuccess} onClose={() => setOpen(false)} />
61
+ </Modal>
62
+ );
63
+ }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import { logoutAction } from './actions';
5
+
6
+ export function LogoutButton() {
7
+ const router = useRouter();
8
+
9
+ const handleLogout = async () => {
10
+ await logoutAction();
11
+ router.refresh();
12
+ };
13
+
14
+ return (
15
+ <button
16
+ type="button"
17
+ onClick={handleLogout}
18
+ className="text-xs text-tertiary hover:text-primary transition-colors"
19
+ >
20
+ Sign out
21
+ </button>
22
+ );
23
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useTransition, useRef, useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { sendMessageAction } from './actions';
6
+
7
+ export function MessageComposer({ contactId }: { contactId: number }) {
8
+ const [body, setBody] = useState('');
9
+ const [error, setError] = useState<string | null>(null);
10
+ const [isPending, startTransition] = useTransition();
11
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
12
+ const router = useRouter();
13
+
14
+ // Auto-resize textarea
15
+ useEffect(() => {
16
+ const el = textareaRef.current;
17
+ if (!el) return;
18
+ el.style.height = 'auto';
19
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
20
+ }, [body]);
21
+
22
+ function submit() {
23
+ if (!body.trim() || isPending) return;
24
+ setError(null);
25
+ startTransition(async () => {
26
+ const result = await sendMessageAction({ contactId, body: body.trim() });
27
+ if (result.error) {
28
+ setError(result.error);
29
+ } else {
30
+ setBody('');
31
+ router.refresh();
32
+ }
33
+ });
34
+ }
35
+
36
+ function handleSubmit(e: React.FormEvent) {
37
+ e.preventDefault();
38
+ submit();
39
+ }
40
+
41
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
42
+ if (e.key === 'Enter' && !e.shiftKey) {
43
+ e.preventDefault();
44
+ submit();
45
+ }
46
+ }
47
+
48
+ return (
49
+ <form onSubmit={handleSubmit} className="border-t border-secondary px-4 py-3">
50
+ {error && (
51
+ <p className="mb-2 text-xs text-red-600">{error}</p>
52
+ )}
53
+ <div className="flex items-end gap-2">
54
+ <textarea
55
+ ref={textareaRef}
56
+ value={body}
57
+ onChange={(e) => setBody(e.target.value)}
58
+ onKeyDown={handleKeyDown}
59
+ placeholder="Type a message… (Enter to send)"
60
+ rows={1}
61
+ disabled={isPending}
62
+ className="flex-1 resize-none rounded-xl border border-gray-300 bg-primary px-3 py-2 text-sm text-primary placeholder-gray-400 focus:border-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-700 transition-colors disabled:opacity-50"
63
+ />
64
+ <button
65
+ type="submit"
66
+ disabled={!body.trim() || isPending}
67
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-900 text-white transition-colors hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed"
68
+ aria-label="Send message"
69
+ >
70
+ {isPending ? (
71
+ <svg className="size-4 animate-spin" fill="none" viewBox="0 0 24 24">
72
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
73
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
74
+ </svg>
75
+ ) : (
76
+ <svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
77
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.269 20.876L5.999 12zm0 0h7.5" />
78
+ </svg>
79
+ )}
80
+ </button>
81
+ </div>
82
+ </form>
83
+ );
84
+ }