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.
- package/dist/design_system/elements/index.js +8 -3
- package/dist/design_system/elements/index.js.map +1 -1
- package/dist/design_system/sections/index.js +203 -106
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +303 -247
- package/dist/index.js.map +1 -1
- package/dist/lib/hooks/index.js +72 -0
- package/dist/lib/hooks/index.js.map +1 -1
- package/dist/lib/server-api.js.map +1 -1
- package/dist/utils/phone-helpers.js +26 -0
- package/dist/utils/phone-helpers.js.map +1 -0
- package/package.json +5 -2
- package/src/design_system/components/ChatWidget.tsx +51 -34
- package/src/design_system/components/DynamicFormFields.tsx +1 -24
- package/src/design_system/elements/modal/modal.tsx +54 -35
- package/src/design_system/portal/LoginForm.tsx +339 -0
- package/src/design_system/portal/LoginModalController.tsx +63 -0
- package/src/design_system/portal/LogoutButton.tsx +23 -0
- package/src/design_system/portal/MessageComposer.tsx +84 -0
- package/src/design_system/portal/PortalPage.tsx +754 -0
- package/src/design_system/portal/RowThumbnail.tsx +76 -0
- package/src/design_system/portal/actions.ts +160 -0
- package/src/design_system/portal/index.ts +5 -0
- package/src/design_system/sections/index.tsx +1 -1
- package/src/design_system/sections/service-menu-section.tsx +7 -108
- package/src/lib/actions.ts +51 -115
- package/src/lib/consumer-session.ts +74 -0
- package/src/lib/hooks/index.ts +2 -0
- package/src/lib/hooks/use-image-cycle.ts +105 -0
- package/src/lib/server-api.ts +7 -6
- package/src/next/routes/chat.ts +30 -58
- package/src/next/routes/consumer-auth.ts +113 -0
- package/src/types/api/consumer.ts +39 -0
- package/src/types/api/offer.ts +1 -1
- package/src/types/api/package.ts +20 -0
- package/src/types/api/service.ts +6 -24
- package/src/types/index.ts +2 -0
- package/src/utils/phone-helpers.ts +27 -0
- package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
- package/dist/contexts/index.d.ts +0 -13
- package/dist/design_system/elements/index.d.ts +0 -372
- package/dist/design_system/logo/keystone-logo.d.ts +0 -6
- package/dist/design_system/sections/index.d.ts +0 -237
- package/dist/form-CpsCONG5.d.ts +0 -151
- package/dist/index.d.ts +0 -76
- package/dist/lib/component-registry.d.ts +0 -13
- package/dist/lib/hooks/index.d.ts +0 -64
- package/dist/lib/server-api.d.ts +0 -43
- package/dist/themes/index.d.ts +0 -16
- package/dist/types/index.d.ts +0 -264
- package/dist/utils/cx.d.ts +0 -15
- package/dist/utils/gradient-placeholder.d.ts +0 -8
- package/dist/utils/is-react-component.d.ts +0 -21
- package/dist/utils/markdown-toc.d.ts +0 -14
- package/dist/utils/photo-helpers.d.ts +0 -37
- 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
|
+
}
|