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.
@@ -1,19 +1,20 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useMemo, useEffect } from '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
- type Step = 'identifier' | 'returning' | 'new';
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
- useEffect(() => {
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 [password, setPassword] = useState('');
38
- const [passwordConfirm, setPasswordConfirm] = useState('');
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
- // Additional contact method collected on the new-user step.
43
- // Kept separate so typing here doesn't affect the identifier-step conditions.
44
- const [additionalPhone, setAdditionalPhone] = useState('');
45
- const [additionalEmail, setAdditionalEmail] = useState('');
46
- // Snapshot of which method the user identified with, set when moving to step 'new'.
47
- const [identifiedWith, setIdentifiedWith] = useState<'email' | 'phone' | null>(null);
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
- const emailVal = null;
63
-
64
- const emailInput = (value: string, onChange: (v: string) => void, required = false) => (
65
- <input
66
- type="email"
67
- value={value}
68
- onChange={(e) => onChange(e.target.value)}
69
- placeholder="you@example.com"
70
- className={inputClass}
71
- autoComplete="email"
72
- required={required}
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 handleContinue = async (e: React.FormEvent) => {
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) { setError('Enter your phone number to continue.'); return; }
106
- setError(null);
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/initiate', {
153
+ const res = await fetch('/api/consumer/send_code', {
110
154
  method: 'POST',
111
155
  headers: { 'Content-Type': 'application/json' },
112
- body: JSON.stringify({ email: emailVal, phone: fullPhone }),
156
+ credentials: 'include',
157
+ body: JSON.stringify({ phone: fullPhone }),
113
158
  });
114
- const result = await res.json().catch(() => ({ exists: null }));
115
- const method = 'phone';
116
- if (result.exists === true) {
117
- await setPixelUserData({ email: emailVal, phone: fullPhone });
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
- } else if (result.exists === false) {
128
- await setPixelUserData({ email: emailVal, phone: fullPhone });
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 handleSignIn = async (e: React.FormEvent) => {
182
+ const handleVerifyCode = async (e: React.FormEvent) => {
146
183
  e.preventDefault();
147
- setError(null);
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/login', {
195
+ const res = await fetch('/api/consumer/verify_code', {
151
196
  method: 'POST',
152
197
  headers: { 'Content-Type': 'application/json' },
153
- body: JSON.stringify({ email: emailVal, phone: fullPhone, password }),
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
- if (result.code === 'not_found' || result.code === 'no_password') {
158
- setStep('new');
159
- setError(null);
160
- return;
161
- }
162
- captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'invalid_credentials' });
163
- setError(result.error || 'Login failed. Please try again.');
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
- captureEvent('portal_login_completed', { flow: 'signin' });
167
- if (onSuccess) onSuccess(); else router.refresh();
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 handleSignUp = async (e: React.FormEvent) => {
177
- e.preventDefault();
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 additionalPhoneDigits = additionalPhone.replace(/\D/g, '');
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
- body: JSON.stringify({
192
- email: emailVal ?? additionalEmailVal,
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
- captureEvent('portal_login_failed', { step: 'signup', reason: result.error || 'signup_failed' });
203
- setError(result.error || 'Signup failed. Please try again.');
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
- captureEvent('portal_login_completed', { flow: 'signup' });
207
- if (onSuccess) onSuccess(); else router.refresh();
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 goBack = () => {
302
+ const goBackToPhone = () => {
217
303
  setStep('identifier');
218
- setPassword('');
219
- setPasswordConfirm('');
220
- setAdditionalPhone('');
221
- setAdditionalEmail('');
222
- setIdentifiedWith(null);
304
+ setVerificationCode('');
305
+ setVerificationToken(null);
306
+ setFirstName('');
307
+ setLastName('');
308
+ setEmail('');
309
+ setResendAvailableAt(null);
223
310
  setError(null);
224
311
  };
225
312
 
226
- const identifierSummary = fullPhone ?? '';
227
-
228
- const headerTitle =
229
- step === 'identifier' ? 'Pricing & Booking Portal' :
230
- step === 'returning' ? (welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome back!') :
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' ? 'Enter your phone number to get started.' :
235
- step === 'returning' ? 'Enter your password to sign in.' :
236
- (welcomeName ? 'Create a password to access your account.' : "Let's get you set up.");
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
- {/* Step: identifier */}
267
- {step === 'identifier' && (
268
- <form onSubmit={handleContinue} className="space-y-3">
269
- <div>
270
- <label className={labelClass}>Phone number</label>
271
- {phoneInput(phoneValue, setPhoneValue, true)}
272
- </div>
273
- <div className="pt-1">
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="submit"
276
- disabled={loading}
277
- 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"
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
- {loading ? 'Looking you up…' : 'Continue'}
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="submit"
303
- disabled={loading}
304
- 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"
406
+ type="button"
407
+ onClick={goBackToPhone}
408
+ className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
305
409
  >
306
- {loading ? 'Signing in…' : 'Sign in'}
410
+ Use a different phone number
307
411
  </button>
308
- </div>
309
- <button
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
- {/* Step: new user / claim account */}
320
- {step === 'new' && (
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
- {emailInput(additionalEmail, setAdditionalEmail, true)}
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="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"
465
+ type="button"
466
+ onClick={goBackToPhone}
467
+ className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
393
468
  >
394
- {loading ? 'Creating account…' : 'Create account'}
469
+ Use a different phone number
395
470
  </button>
396
- </div>
397
- <button
398
- type="button"
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 (