keystone-design-bootstrap 1.0.81 → 1.0.83
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/sections/index.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tracking/index.d.ts +20 -1
- package/dist/tracking/index.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/portal/LoginForm.tsx +286 -218
- package/src/design_system/portal/LoginModalController.tsx +4 -2
- package/src/next/layouts/root-layout.tsx +5 -4
- package/src/next/routes/consumer-auth.ts +106 -1
- package/src/tracking/captureEvent.ts +27 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { countries } from '../../utils/countries';
|
|
6
6
|
import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
|
|
7
7
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
8
8
|
import { captureEvent } from '../../tracking/captureEvent';
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
type Step = 'identifier' | 'verify' | 'profile';
|
|
11
|
+
type FunnelStep = 'identifier' | 'signin' | 'signup';
|
|
10
12
|
|
|
11
13
|
interface LoginFormProps {
|
|
12
14
|
onSuccess?: () => void;
|
|
13
15
|
onClose?: () => void;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
// text-base (16px) prevents iOS from auto-zooming when the input is focused.
|
|
17
18
|
const inputClass =
|
|
18
19
|
'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
20
|
const labelClass = 'block text-sm text-secondary mb-1';
|
|
@@ -22,31 +23,30 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
22
23
|
const router = useRouter();
|
|
23
24
|
const [step, setStep] = useState<Step>('identifier');
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
captureEvent('portal_login_started');
|
|
27
|
-
}, []);
|
|
28
|
-
|
|
29
|
-
// Identifier step state
|
|
30
|
-
const [phoneValue, setPhoneValue] = useState(''); // formatted national number
|
|
26
|
+
const [phoneValue, setPhoneValue] = useState('');
|
|
31
27
|
const [selectedCountry, setSelectedCountry] = useState('US');
|
|
28
|
+
const [verificationCode, setVerificationCode] = useState('');
|
|
29
|
+
const [verificationToken, setVerificationToken] = useState<string | null>(null);
|
|
32
30
|
|
|
33
|
-
// Later steps
|
|
34
|
-
const [welcomeName, setWelcomeName] = useState<string | null>(null);
|
|
35
31
|
const [firstName, setFirstName] = useState('');
|
|
36
32
|
const [lastName, setLastName] = useState('');
|
|
37
|
-
const [
|
|
38
|
-
|
|
33
|
+
const [email, setEmail] = useState('');
|
|
34
|
+
|
|
35
|
+
const [resendAvailableAt, setResendAvailableAt] = useState<number | null>(null);
|
|
36
|
+
const [secondsUntilResend, setSecondsUntilResend] = useState(0);
|
|
37
|
+
|
|
39
38
|
const [error, setError] = useState<string | null>(null);
|
|
40
39
|
const [loading, setLoading] = useState(false);
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
captureEvent('portal_login_started');
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const funnelStep: FunnelStep = step === 'identifier' ? 'identifier' : step === 'verify' ? 'signin' : 'signup';
|
|
47
|
+
captureEvent('portal_login_step_viewed', { step: funnelStep });
|
|
48
|
+
}, [step]);
|
|
48
49
|
|
|
49
|
-
// Phone helpers
|
|
50
50
|
const country = useMemo(() => countries.find((c) => c.code === selectedCountry), [selectedCountry]);
|
|
51
51
|
const phoneCode = country ? (country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`) : '+1';
|
|
52
52
|
const nationalMask = useMemo(() => getNationalMask(country), [country]);
|
|
@@ -55,23 +55,22 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
55
55
|
() => countries.map((c) => ({ label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`, value: c.code })),
|
|
56
56
|
[]
|
|
57
57
|
);
|
|
58
|
-
|
|
59
|
-
// Computed API values
|
|
60
58
|
const phoneDigits = phoneValue.replace(/\D/g, '');
|
|
61
59
|
const fullPhone = phoneDigits.length > 0 ? `${phoneCode}${phoneDigits}` : null;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!resendAvailableAt) {
|
|
63
|
+
setSecondsUntilResend(0);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const tick = () => {
|
|
67
|
+
const remaining = Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000));
|
|
68
|
+
setSecondsUntilResend(remaining);
|
|
69
|
+
};
|
|
70
|
+
tick();
|
|
71
|
+
const interval = window.setInterval(tick, 1000);
|
|
72
|
+
return () => window.clearInterval(interval);
|
|
73
|
+
}, [resendAvailableAt]);
|
|
75
74
|
|
|
76
75
|
const phoneInput = (value: string, onChange: (v: string) => void, required = false) => (
|
|
77
76
|
<div className="flex rounded-input border border-primary overflow-hidden focus-within:border-brand focus-within:ring-1 focus-within:ring-brand transition-colors">
|
|
@@ -100,144 +99,233 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
100
99
|
</div>
|
|
101
100
|
);
|
|
102
101
|
|
|
103
|
-
const
|
|
102
|
+
const finishPasswordlessAuth = async ({
|
|
103
|
+
token,
|
|
104
|
+
firstNameValue,
|
|
105
|
+
lastNameValue,
|
|
106
|
+
emailValue,
|
|
107
|
+
}: {
|
|
108
|
+
token: string;
|
|
109
|
+
firstNameValue: string;
|
|
110
|
+
lastNameValue: string;
|
|
111
|
+
emailValue: string;
|
|
112
|
+
}): Promise<boolean> => {
|
|
113
|
+
const res = await fetch('/api/consumer/passwordless_auth', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
credentials: 'include',
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
phone: fullPhone,
|
|
119
|
+
verification_token: token,
|
|
120
|
+
first_name: firstNameValue.trim(),
|
|
121
|
+
last_name: lastNameValue.trim(),
|
|
122
|
+
email: emailValue.trim(),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
const result = await res.json().catch(() => ({}));
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
setError(result.error || 'Authentication failed. Please try again.');
|
|
128
|
+
captureEvent('portal_login_failed', { step: 'signup', reason: result.error || 'passwordless_auth_failed' });
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
firePixelEvent('Lead');
|
|
133
|
+
captureEvent('portal_login_step_advanced', { from_step: 'signup', to_step: 'authenticated', reason: 'passwordless_auth_success' });
|
|
134
|
+
captureEvent('portal_login_completed', { flow: 'signup' });
|
|
135
|
+
setResendAvailableAt(null);
|
|
136
|
+
setSecondsUntilResend(0);
|
|
137
|
+
if (onSuccess) onSuccess(); else router.refresh();
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleSendCode = async (e: React.FormEvent) => {
|
|
104
142
|
e.preventDefault();
|
|
105
|
-
if (!fullPhone) {
|
|
106
|
-
|
|
143
|
+
if (!fullPhone) {
|
|
144
|
+
setError('Enter your phone number to continue.');
|
|
145
|
+
captureEvent('portal_login_failed', { step: 'identifier', reason: 'validation_missing_phone' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
107
149
|
setLoading(true);
|
|
150
|
+
setError(null);
|
|
151
|
+
captureEvent('portal_login_step_submitted', { step: 'identifier' });
|
|
108
152
|
try {
|
|
109
|
-
const res = await fetch('/api/consumer/
|
|
153
|
+
const res = await fetch('/api/consumer/send_code', {
|
|
110
154
|
method: 'POST',
|
|
111
155
|
headers: { 'Content-Type': 'application/json' },
|
|
112
|
-
|
|
156
|
+
credentials: 'include',
|
|
157
|
+
body: JSON.stringify({ phone: fullPhone }),
|
|
113
158
|
});
|
|
114
|
-
const result = await res.json().catch(() => ({
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
firePixelEvent('Lead');
|
|
119
|
-
captureEvent('portal_login_identified', { method, user_exists: true });
|
|
120
|
-
setWelcomeName(result.firstName ?? null);
|
|
121
|
-
if (result.hasPassword === false) {
|
|
122
|
-
setIdentifiedWith('phone');
|
|
123
|
-
setStep('new');
|
|
124
|
-
} else {
|
|
125
|
-
setStep('returning');
|
|
159
|
+
const result = await res.json().catch(() => ({}));
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
if (result.code === 'cooldown_active' && typeof result.retry_in_seconds === 'number') {
|
|
162
|
+
setResendAvailableAt(Date.now() + result.retry_in_seconds * 1000);
|
|
126
163
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
firePixelEvent('Lead');
|
|
130
|
-
captureEvent('portal_login_identified', { method, user_exists: false });
|
|
131
|
-
setIdentifiedWith('phone');
|
|
132
|
-
setStep('new');
|
|
133
|
-
} else {
|
|
134
|
-
captureEvent('portal_login_failed', { step: 'identifier', reason: 'unknown_error' });
|
|
135
|
-
setError('Something went wrong. Please check your connection and try again.');
|
|
164
|
+
setError(result.error || 'Could not send verification code.');
|
|
165
|
+
return;
|
|
136
166
|
}
|
|
167
|
+
|
|
168
|
+
await setPixelUserData({ phone: fullPhone });
|
|
169
|
+
firePixelEvent('Lead');
|
|
170
|
+
|
|
171
|
+
setResendAvailableAt(result.resend_available_at ? new Date(result.resend_available_at).getTime() : Date.now() + 5 * 60 * 1000);
|
|
172
|
+
setStep('verify');
|
|
173
|
+
captureEvent('portal_login_step_advanced', { from_step: 'identifier', to_step: 'signin', reason: 'verification_code_sent' });
|
|
137
174
|
} catch {
|
|
175
|
+
setError('Could not send verification code.');
|
|
138
176
|
captureEvent('portal_login_failed', { step: 'identifier', reason: 'network_error' });
|
|
139
|
-
setError('Something went wrong. Please check your connection and try again.');
|
|
140
177
|
} finally {
|
|
141
178
|
setLoading(false);
|
|
142
179
|
}
|
|
143
180
|
};
|
|
144
181
|
|
|
145
|
-
const
|
|
182
|
+
const handleVerifyCode = async (e: React.FormEvent) => {
|
|
146
183
|
e.preventDefault();
|
|
147
|
-
|
|
184
|
+
const code = verificationCode.replace(/\D/g, '');
|
|
185
|
+
if (!fullPhone || code.length !== 4) {
|
|
186
|
+
setError('Enter the 4-digit code sent to your phone.');
|
|
187
|
+
captureEvent('portal_login_failed', { step: 'signin', reason: 'validation_invalid_code' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
148
191
|
setLoading(true);
|
|
192
|
+
setError(null);
|
|
193
|
+
captureEvent('portal_login_step_submitted', { step: 'signin' });
|
|
149
194
|
try {
|
|
150
|
-
const res = await fetch('/api/consumer/
|
|
195
|
+
const res = await fetch('/api/consumer/verify_code', {
|
|
151
196
|
method: 'POST',
|
|
152
197
|
headers: { 'Content-Type': 'application/json' },
|
|
153
|
-
|
|
198
|
+
credentials: 'include',
|
|
199
|
+
body: JSON.stringify({ phone: fullPhone, code }),
|
|
154
200
|
});
|
|
155
201
|
const result = await res.json().catch(() => ({}));
|
|
156
|
-
if (!res.ok) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
202
|
+
if (!res.ok || !result.verification_token) {
|
|
203
|
+
setError(result.error || 'That code is invalid or expired.');
|
|
204
|
+
captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'verification_failed' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setVerificationToken(result.verification_token);
|
|
209
|
+
setFirstName(result.first_name || '');
|
|
210
|
+
setLastName(result.last_name || '');
|
|
211
|
+
setEmail(result.email || '');
|
|
212
|
+
|
|
213
|
+
const hasCompleteExistingProfile =
|
|
214
|
+
typeof result.first_name === 'string' && result.first_name.trim().length > 0 &&
|
|
215
|
+
typeof result.last_name === 'string' && result.last_name.trim().length > 0 &&
|
|
216
|
+
typeof result.email === 'string' && result.email.trim().length > 0;
|
|
217
|
+
|
|
218
|
+
if (hasCompleteExistingProfile) {
|
|
219
|
+
captureEvent('portal_login_step_advanced', {
|
|
220
|
+
from_step: 'signin',
|
|
221
|
+
to_step: 'authenticated',
|
|
222
|
+
reason: 'verification_success_profile_complete',
|
|
223
|
+
});
|
|
224
|
+
await finishPasswordlessAuth({
|
|
225
|
+
token: result.verification_token,
|
|
226
|
+
firstNameValue: result.first_name,
|
|
227
|
+
lastNameValue: result.last_name,
|
|
228
|
+
emailValue: result.email,
|
|
229
|
+
});
|
|
164
230
|
return;
|
|
165
231
|
}
|
|
166
|
-
|
|
167
|
-
|
|
232
|
+
|
|
233
|
+
setStep('profile');
|
|
234
|
+
captureEvent('portal_login_step_advanced', { from_step: 'signin', to_step: 'signup', reason: 'verification_success' });
|
|
168
235
|
} catch {
|
|
236
|
+
setError('That code is invalid or expired.');
|
|
169
237
|
captureEvent('portal_login_failed', { step: 'signin', reason: 'network_error' });
|
|
170
|
-
setError('Something went wrong. Please check your connection and try again.');
|
|
171
238
|
} finally {
|
|
172
239
|
setLoading(false);
|
|
173
240
|
}
|
|
174
241
|
};
|
|
175
242
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
if (!welcomeName && !firstName.trim()) { setError('Please enter your first name.'); return; }
|
|
179
|
-
if (!welcomeName && !lastName.trim()) { setError('Please enter your last name.'); return; }
|
|
180
|
-
if (password !== passwordConfirm) { setError('Passwords do not match.'); return; }
|
|
181
|
-
setError(null);
|
|
243
|
+
const handleResendCode = async () => {
|
|
244
|
+
if (!fullPhone || loading || secondsUntilResend > 0) return;
|
|
182
245
|
setLoading(true);
|
|
246
|
+
setError(null);
|
|
183
247
|
try {
|
|
184
|
-
const
|
|
185
|
-
const additionalFullPhone = additionalPhoneDigits.length > 0 ? `${phoneCode}${additionalPhoneDigits}` : null;
|
|
186
|
-
const additionalEmailVal = additionalEmail.trim() || null;
|
|
187
|
-
|
|
188
|
-
const res = await fetch('/api/consumer/signup', {
|
|
248
|
+
const res = await fetch('/api/consumer/send_code', {
|
|
189
249
|
method: 'POST',
|
|
190
250
|
headers: { 'Content-Type': 'application/json' },
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
phone: fullPhone ?? additionalFullPhone,
|
|
194
|
-
password,
|
|
195
|
-
password_confirmation: passwordConfirm,
|
|
196
|
-
first_name: firstName.trim() || undefined,
|
|
197
|
-
last_name: lastName.trim() || undefined,
|
|
198
|
-
}),
|
|
251
|
+
credentials: 'include',
|
|
252
|
+
body: JSON.stringify({ phone: fullPhone }),
|
|
199
253
|
});
|
|
200
254
|
const result = await res.json().catch(() => ({}));
|
|
201
255
|
if (!res.ok) {
|
|
202
|
-
|
|
203
|
-
|
|
256
|
+
if (result.code === 'cooldown_active' && typeof result.retry_in_seconds === 'number') {
|
|
257
|
+
setResendAvailableAt(Date.now() + result.retry_in_seconds * 1000);
|
|
258
|
+
}
|
|
259
|
+
setError(result.error || 'Could not request a new code yet.');
|
|
204
260
|
return;
|
|
205
261
|
}
|
|
206
|
-
|
|
207
|
-
|
|
262
|
+
|
|
263
|
+
setVerificationCode('');
|
|
264
|
+
setResendAvailableAt(result.resend_available_at ? new Date(result.resend_available_at).getTime() : Date.now() + 5 * 60 * 1000);
|
|
208
265
|
} catch {
|
|
266
|
+
setError('Could not request a new code yet.');
|
|
267
|
+
} finally {
|
|
268
|
+
setLoading(false);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const handleProfileSubmit = async (e: React.FormEvent) => {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
if (!fullPhone || !verificationToken) {
|
|
275
|
+
setError('Verification expired. Please request a new code.');
|
|
276
|
+
setStep('identifier');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!firstName.trim() || !lastName.trim() || !email.trim()) {
|
|
280
|
+
setError('Please enter your first name, last name, and email.');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
setLoading(true);
|
|
285
|
+
setError(null);
|
|
286
|
+
captureEvent('portal_login_step_submitted', { step: 'signup' });
|
|
287
|
+
try {
|
|
288
|
+
await finishPasswordlessAuth({
|
|
289
|
+
token: verificationToken,
|
|
290
|
+
firstNameValue: firstName,
|
|
291
|
+
lastNameValue: lastName,
|
|
292
|
+
emailValue: email,
|
|
293
|
+
});
|
|
294
|
+
} catch {
|
|
295
|
+
setError('Authentication failed. Please try again.');
|
|
209
296
|
captureEvent('portal_login_failed', { step: 'signup', reason: 'network_error' });
|
|
210
|
-
setError('Something went wrong. Please check your connection and try again.');
|
|
211
297
|
} finally {
|
|
212
298
|
setLoading(false);
|
|
213
299
|
}
|
|
214
300
|
};
|
|
215
301
|
|
|
216
|
-
const
|
|
302
|
+
const goBackToPhone = () => {
|
|
217
303
|
setStep('identifier');
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
304
|
+
setVerificationCode('');
|
|
305
|
+
setVerificationToken(null);
|
|
306
|
+
setFirstName('');
|
|
307
|
+
setLastName('');
|
|
308
|
+
setEmail('');
|
|
309
|
+
setResendAvailableAt(null);
|
|
223
310
|
setError(null);
|
|
224
311
|
};
|
|
225
312
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
(welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome!');
|
|
313
|
+
const formatCountdown = (totalSeconds: number) => {
|
|
314
|
+
const mins = Math.floor(totalSeconds / 60);
|
|
315
|
+
const secs = totalSeconds % 60;
|
|
316
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
317
|
+
};
|
|
232
318
|
|
|
319
|
+
const headerTitle = step === 'identifier' ? 'Pricing & Booking Portal' : step === 'verify' ? 'Verify your phone' : 'Complete your profile';
|
|
233
320
|
const headerSubtitle =
|
|
234
|
-
step === 'identifier'
|
|
235
|
-
|
|
236
|
-
|
|
321
|
+
step === 'identifier'
|
|
322
|
+
? 'Enter your phone number to get started.'
|
|
323
|
+
: step === 'verify'
|
|
324
|
+
? 'We sent a 4-digit verification code by text.'
|
|
325
|
+
: 'Enter your first name, last name, and email.';
|
|
237
326
|
|
|
238
327
|
return (
|
|
239
328
|
<div>
|
|
240
|
-
{/* Header */}
|
|
241
329
|
<div className="flex items-start justify-between gap-4 mb-5">
|
|
242
330
|
<div>
|
|
243
331
|
<h3 className="text-lg font-normal text-primary">{headerTitle}</h3>
|
|
@@ -263,64 +351,69 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
263
351
|
</div>
|
|
264
352
|
)}
|
|
265
353
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
354
|
+
<div className="relative overflow-hidden transition-all duration-300 ease-out">
|
|
355
|
+
{step === 'identifier' && (
|
|
356
|
+
<form onSubmit={handleSendCode} className="space-y-3">
|
|
357
|
+
<div>
|
|
358
|
+
<label className={labelClass}>Phone number</label>
|
|
359
|
+
{phoneInput(phoneValue, setPhoneValue, true)}
|
|
360
|
+
</div>
|
|
361
|
+
<div className="pt-1">
|
|
362
|
+
<button
|
|
363
|
+
type="submit"
|
|
364
|
+
disabled={loading}
|
|
365
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
366
|
+
>
|
|
367
|
+
{loading ? 'Sending code…' : 'Continue'}
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
</form>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{step === 'verify' && (
|
|
374
|
+
<form onSubmit={handleVerifyCode} className="space-y-3">
|
|
375
|
+
<div>
|
|
376
|
+
<label className={labelClass}>Verification code</label>
|
|
377
|
+
<input
|
|
378
|
+
type="text"
|
|
379
|
+
value={verificationCode}
|
|
380
|
+
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
|
381
|
+
placeholder="0000"
|
|
382
|
+
className={inputClass}
|
|
383
|
+
autoComplete="one-time-code"
|
|
384
|
+
inputMode="numeric"
|
|
385
|
+
required
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<div className="pt-1">
|
|
389
|
+
<button
|
|
390
|
+
type="submit"
|
|
391
|
+
disabled={loading}
|
|
392
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
393
|
+
>
|
|
394
|
+
{loading ? 'Verifying…' : 'Verify code'}
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
274
397
|
<button
|
|
275
|
-
type="
|
|
276
|
-
|
|
277
|
-
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={handleResendCode}
|
|
400
|
+
disabled={loading || secondsUntilResend > 0}
|
|
401
|
+
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors disabled:opacity-50"
|
|
278
402
|
>
|
|
279
|
-
{
|
|
403
|
+
{secondsUntilResend > 0 ? `Request new code in ${formatCountdown(secondsUntilResend)}` : 'Request new code'}
|
|
280
404
|
</button>
|
|
281
|
-
</div>
|
|
282
|
-
</form>
|
|
283
|
-
)}
|
|
284
|
-
|
|
285
|
-
{/* Step: returning user */}
|
|
286
|
-
{step === 'returning' && (
|
|
287
|
-
<form onSubmit={handleSignIn} className="space-y-3">
|
|
288
|
-
<div>
|
|
289
|
-
<label className={labelClass}>Password</label>
|
|
290
|
-
<input
|
|
291
|
-
type="password"
|
|
292
|
-
value={password}
|
|
293
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
294
|
-
placeholder="••••••••"
|
|
295
|
-
className={inputClass}
|
|
296
|
-
required
|
|
297
|
-
autoComplete="current-password"
|
|
298
|
-
/>
|
|
299
|
-
</div>
|
|
300
|
-
<div className="pt-1">
|
|
301
405
|
<button
|
|
302
|
-
type="
|
|
303
|
-
|
|
304
|
-
className="w-full
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={goBackToPhone}
|
|
408
|
+
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
305
409
|
>
|
|
306
|
-
|
|
410
|
+
← Use a different phone number
|
|
307
411
|
</button>
|
|
308
|
-
</
|
|
309
|
-
|
|
310
|
-
type="button"
|
|
311
|
-
onClick={goBack}
|
|
312
|
-
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
313
|
-
>
|
|
314
|
-
← {identifierSummary ? `Not ${identifierSummary}?` : 'Change phone number'}
|
|
315
|
-
</button>
|
|
316
|
-
</form>
|
|
317
|
-
)}
|
|
412
|
+
</form>
|
|
413
|
+
)}
|
|
318
414
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
<form onSubmit={handleSignUp} className="space-y-3">
|
|
322
|
-
{/* Only ask for name if we don't already know who they are */}
|
|
323
|
-
{!welcomeName && (
|
|
415
|
+
{step === 'profile' && (
|
|
416
|
+
<form onSubmit={handleProfileSubmit} className="space-y-3">
|
|
324
417
|
<div className="grid grid-cols-2 gap-3">
|
|
325
418
|
<div>
|
|
326
419
|
<label className={labelClass}>First name</label>
|
|
@@ -347,62 +440,37 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
347
440
|
/>
|
|
348
441
|
</div>
|
|
349
442
|
</div>
|
|
350
|
-
)}
|
|
351
|
-
{/* Collect the missing contact method so the business can reach out */}
|
|
352
|
-
{identifiedWith === 'email' && (
|
|
353
|
-
<div>
|
|
354
|
-
<label className={labelClass}>Phone number</label>
|
|
355
|
-
{phoneInput(additionalPhone, setAdditionalPhone, true)}
|
|
356
|
-
</div>
|
|
357
|
-
)}
|
|
358
|
-
{identifiedWith === 'phone' && (
|
|
359
443
|
<div>
|
|
360
444
|
<label className={labelClass}>Email address</label>
|
|
361
|
-
|
|
445
|
+
<input
|
|
446
|
+
type="email"
|
|
447
|
+
value={email}
|
|
448
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
449
|
+
placeholder="you@example.com"
|
|
450
|
+
className={inputClass}
|
|
451
|
+
autoComplete="email"
|
|
452
|
+
required
|
|
453
|
+
/>
|
|
454
|
+
</div>
|
|
455
|
+
<div className="pt-1">
|
|
456
|
+
<button
|
|
457
|
+
type="submit"
|
|
458
|
+
disabled={loading}
|
|
459
|
+
className="w-full rounded-interactive bg-brand-solid px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-solid_hover transition-colors disabled:opacity-50"
|
|
460
|
+
>
|
|
461
|
+
{loading ? 'Finishing…' : 'Continue'}
|
|
462
|
+
</button>
|
|
362
463
|
</div>
|
|
363
|
-
)}
|
|
364
|
-
<div>
|
|
365
|
-
<label className={labelClass}>Password</label>
|
|
366
|
-
<input
|
|
367
|
-
type="password"
|
|
368
|
-
value={password}
|
|
369
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
370
|
-
placeholder="••••••••"
|
|
371
|
-
className={inputClass}
|
|
372
|
-
required
|
|
373
|
-
autoComplete="new-password"
|
|
374
|
-
/>
|
|
375
|
-
</div>
|
|
376
|
-
<div>
|
|
377
|
-
<label className={labelClass}>Confirm password</label>
|
|
378
|
-
<input
|
|
379
|
-
type="password"
|
|
380
|
-
value={passwordConfirm}
|
|
381
|
-
onChange={(e) => setPasswordConfirm(e.target.value)}
|
|
382
|
-
placeholder="••••••••"
|
|
383
|
-
className={inputClass}
|
|
384
|
-
required
|
|
385
|
-
autoComplete="new-password"
|
|
386
|
-
/>
|
|
387
|
-
</div>
|
|
388
|
-
<div className="pt-1">
|
|
389
464
|
<button
|
|
390
|
-
type="
|
|
391
|
-
|
|
392
|
-
className="w-full
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={goBackToPhone}
|
|
467
|
+
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
393
468
|
>
|
|
394
|
-
|
|
469
|
+
← Use a different phone number
|
|
395
470
|
</button>
|
|
396
|
-
</
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
onClick={goBack}
|
|
400
|
-
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
401
|
-
>
|
|
402
|
-
← Back
|
|
403
|
-
</button>
|
|
404
|
-
</form>
|
|
405
|
-
)}
|
|
471
|
+
</form>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
406
474
|
</div>
|
|
407
475
|
);
|
|
408
476
|
}
|
|
@@ -60,10 +60,12 @@ export function LoginModalController() {
|
|
|
60
60
|
// Close the modal once the router transition has fully completed and the
|
|
61
61
|
// server component has re-rendered with the new authenticated state.
|
|
62
62
|
useEffect(() => {
|
|
63
|
-
if (refreshing && !isPending)
|
|
63
|
+
if (!(refreshing && !isPending)) return;
|
|
64
|
+
const timeout = window.setTimeout(() => {
|
|
64
65
|
setOpen(false);
|
|
65
66
|
setRefreshing(false);
|
|
66
|
-
}
|
|
67
|
+
}, 0);
|
|
68
|
+
return () => window.clearTimeout(timeout);
|
|
67
69
|
}, [refreshing, isPending]);
|
|
68
70
|
|
|
69
71
|
return (
|