keystone-design-bootstrap 1.0.82 → 1.0.84

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,12 +1,13 @@
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';
10
11
  type FunnelStep = 'identifier' | 'signin' | 'signup';
11
12
 
12
13
  interface LoginFormProps {
@@ -14,7 +15,6 @@ interface LoginFormProps {
14
15
  onClose?: () => void;
15
16
  }
16
17
 
17
- // text-base (16px) prevents iOS from auto-zooming when the input is focused.
18
18
  const inputClass =
19
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';
20
20
  const labelClass = 'block text-sm text-secondary mb-1';
@@ -23,37 +23,30 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
23
23
  const router = useRouter();
24
24
  const [step, setStep] = useState<Step>('identifier');
25
25
 
26
- useEffect(() => {
27
- captureEvent('portal_login_started');
28
- }, []);
29
-
30
- const toFunnelStep = (value: Step): FunnelStep => (value === 'returning' ? 'signin' : value === 'new' ? 'signup' : 'identifier');
31
-
32
- useEffect(() => {
33
- captureEvent('portal_login_step_viewed', { step: toFunnelStep(step) });
34
- }, [step]);
35
-
36
- // Identifier step state
37
- const [phoneValue, setPhoneValue] = useState(''); // formatted national number
26
+ const [phoneValue, setPhoneValue] = useState('');
38
27
  const [selectedCountry, setSelectedCountry] = useState('US');
28
+ const [verificationCode, setVerificationCode] = useState('');
29
+ const [verificationToken, setVerificationToken] = useState<string | null>(null);
39
30
 
40
- // Later steps
41
- const [welcomeName, setWelcomeName] = useState<string | null>(null);
42
31
  const [firstName, setFirstName] = useState('');
43
32
  const [lastName, setLastName] = useState('');
44
- const [password, setPassword] = useState('');
45
- 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
+
46
38
  const [error, setError] = useState<string | null>(null);
47
39
  const [loading, setLoading] = useState(false);
48
40
 
49
- // Additional contact method collected on the new-user step.
50
- // Kept separate so typing here doesn't affect the identifier-step conditions.
51
- const [additionalPhone, setAdditionalPhone] = useState('');
52
- const [additionalEmail, setAdditionalEmail] = useState('');
53
- // Snapshot of which method the user identified with, set when moving to step 'new'.
54
- 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]);
55
49
 
56
- // Phone helpers
57
50
  const country = useMemo(() => countries.find((c) => c.code === selectedCountry), [selectedCountry]);
58
51
  const phoneCode = country ? (country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`) : '+1';
59
52
  const nationalMask = useMemo(() => getNationalMask(country), [country]);
@@ -62,23 +55,22 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
62
55
  () => countries.map((c) => ({ label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`, value: c.code })),
63
56
  []
64
57
  );
65
-
66
- // Computed API values
67
58
  const phoneDigits = phoneValue.replace(/\D/g, '');
68
59
  const fullPhone = phoneDigits.length > 0 ? `${phoneCode}${phoneDigits}` : null;
69
- const emailVal = null;
70
-
71
- const emailInput = (value: string, onChange: (v: string) => void, required = false) => (
72
- <input
73
- type="email"
74
- value={value}
75
- onChange={(e) => onChange(e.target.value)}
76
- placeholder="you@example.com"
77
- className={inputClass}
78
- autoComplete="email"
79
- required={required}
80
- />
81
- );
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]);
82
74
 
83
75
  const phoneInput = (value: string, onChange: (v: string) => void, required = false) => (
84
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">
@@ -107,197 +99,233 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
107
99
  </div>
108
100
  );
109
101
 
110
- 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) => {
111
142
  e.preventDefault();
112
143
  if (!fullPhone) {
113
- captureEvent('portal_login_failed', { step: 'identifier', reason: 'validation_missing_phone' });
114
144
  setError('Enter your phone number to continue.');
145
+ captureEvent('portal_login_failed', { step: 'identifier', reason: 'validation_missing_phone' });
115
146
  return;
116
147
  }
117
- captureEvent('portal_login_step_submitted', { step: 'identifier' });
118
- setError(null);
148
+
119
149
  setLoading(true);
150
+ setError(null);
151
+ captureEvent('portal_login_step_submitted', { step: 'identifier' });
120
152
  try {
121
- const res = await fetch('/api/consumer/initiate', {
153
+ const res = await fetch('/api/consumer/send_code', {
122
154
  method: 'POST',
123
155
  headers: { 'Content-Type': 'application/json' },
124
- body: JSON.stringify({ email: emailVal, phone: fullPhone }),
156
+ credentials: 'include',
157
+ body: JSON.stringify({ phone: fullPhone }),
125
158
  });
126
- const result = await res.json().catch(() => ({ exists: null }));
127
- const method = 'phone';
128
- if (result.exists === true) {
129
- await setPixelUserData({ email: emailVal, phone: fullPhone });
130
- firePixelEvent('Lead');
131
- captureEvent('portal_login_identified', { method, user_exists: true });
132
- setWelcomeName(result.firstName ?? null);
133
- if (result.hasPassword === false) {
134
- captureEvent('portal_login_step_advanced', {
135
- from_step: 'identifier',
136
- to_step: 'signup',
137
- reason: 'existing_user_without_password',
138
- });
139
- setIdentifiedWith('phone');
140
- setStep('new');
141
- } else {
142
- captureEvent('portal_login_step_advanced', {
143
- from_step: 'identifier',
144
- to_step: 'signin',
145
- reason: 'existing_user_with_password',
146
- });
147
- 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);
148
163
  }
149
- } else if (result.exists === false) {
150
- await setPixelUserData({ email: emailVal, phone: fullPhone });
151
- firePixelEvent('Lead');
152
- captureEvent('portal_login_identified', { method, user_exists: false });
153
- captureEvent('portal_login_step_advanced', {
154
- from_step: 'identifier',
155
- to_step: 'signup',
156
- reason: 'new_user',
157
- });
158
- setIdentifiedWith('phone');
159
- setStep('new');
160
- } else {
161
- captureEvent('portal_login_failed', { step: 'identifier', reason: 'unknown_error' });
162
- setError('Something went wrong. Please check your connection and try again.');
164
+ setError(result.error || 'Could not send verification code.');
165
+ return;
163
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' });
164
174
  } catch {
175
+ setError('Could not send verification code.');
165
176
  captureEvent('portal_login_failed', { step: 'identifier', reason: 'network_error' });
166
- setError('Something went wrong. Please check your connection and try again.');
167
177
  } finally {
168
178
  setLoading(false);
169
179
  }
170
180
  };
171
181
 
172
- const handleSignIn = async (e: React.FormEvent) => {
182
+ const handleVerifyCode = async (e: React.FormEvent) => {
173
183
  e.preventDefault();
174
- captureEvent('portal_login_step_submitted', { step: 'signin' });
175
- 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
+
176
191
  setLoading(true);
192
+ setError(null);
193
+ captureEvent('portal_login_step_submitted', { step: 'signin' });
177
194
  try {
178
- const res = await fetch('/api/consumer/login', {
195
+ const res = await fetch('/api/consumer/verify_code', {
179
196
  method: 'POST',
180
197
  headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({ email: emailVal, phone: fullPhone, password }),
198
+ credentials: 'include',
199
+ body: JSON.stringify({ phone: fullPhone, code }),
182
200
  });
183
201
  const result = await res.json().catch(() => ({}));
184
- if (!res.ok) {
185
- if (result.code === 'not_found' || result.code === 'no_password') {
186
- captureEvent('portal_login_step_advanced', {
187
- from_step: 'signin',
188
- to_step: 'signup',
189
- reason: 'signin_requires_signup',
190
- });
191
- setStep('new');
192
- setError(null);
193
- return;
194
- }
195
- captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'invalid_credentials' });
196
- 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' });
197
205
  return;
198
206
  }
199
- captureEvent('portal_login_step_advanced', {
200
- from_step: 'signin',
201
- to_step: 'authenticated',
202
- reason: 'signin_success',
203
- });
204
- captureEvent('portal_login_completed', { flow: 'signin' });
205
- if (onSuccess) onSuccess(); else router.refresh();
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
+ });
230
+ return;
231
+ }
232
+
233
+ setStep('profile');
234
+ captureEvent('portal_login_step_advanced', { from_step: 'signin', to_step: 'signup', reason: 'verification_success' });
206
235
  } catch {
236
+ setError('That code is invalid or expired.');
207
237
  captureEvent('portal_login_failed', { step: 'signin', reason: 'network_error' });
208
- setError('Something went wrong. Please check your connection and try again.');
209
238
  } finally {
210
239
  setLoading(false);
211
240
  }
212
241
  };
213
242
 
214
- const handleSignUp = async (e: React.FormEvent) => {
215
- e.preventDefault();
216
- if (!welcomeName && !firstName.trim()) {
217
- captureEvent('portal_login_failed', { step: 'signup', reason: 'validation_missing_first_name' });
218
- setError('Please enter your first name.');
219
- return;
220
- }
221
- if (!welcomeName && !lastName.trim()) {
222
- captureEvent('portal_login_failed', { step: 'signup', reason: 'validation_missing_last_name' });
223
- setError('Please enter your last name.');
224
- return;
225
- }
226
- if (password !== passwordConfirm) {
227
- captureEvent('portal_login_failed', { step: 'signup', reason: 'validation_password_mismatch' });
228
- setError('Passwords do not match.');
229
- return;
230
- }
231
- captureEvent('portal_login_step_submitted', { step: 'signup' });
232
- setError(null);
243
+ const handleResendCode = async () => {
244
+ if (!fullPhone || loading || secondsUntilResend > 0) return;
233
245
  setLoading(true);
246
+ setError(null);
234
247
  try {
235
- const additionalPhoneDigits = additionalPhone.replace(/\D/g, '');
236
- const additionalFullPhone = additionalPhoneDigits.length > 0 ? `${phoneCode}${additionalPhoneDigits}` : null;
237
- const additionalEmailVal = additionalEmail.trim() || null;
238
-
239
- const res = await fetch('/api/consumer/signup', {
248
+ const res = await fetch('/api/consumer/send_code', {
240
249
  method: 'POST',
241
250
  headers: { 'Content-Type': 'application/json' },
242
- body: JSON.stringify({
243
- email: emailVal ?? additionalEmailVal,
244
- phone: fullPhone ?? additionalFullPhone,
245
- password,
246
- password_confirmation: passwordConfirm,
247
- first_name: firstName.trim() || undefined,
248
- last_name: lastName.trim() || undefined,
249
- }),
251
+ credentials: 'include',
252
+ body: JSON.stringify({ phone: fullPhone }),
250
253
  });
251
254
  const result = await res.json().catch(() => ({}));
252
255
  if (!res.ok) {
253
- captureEvent('portal_login_failed', { step: 'signup', reason: result.error || 'signup_failed' });
254
- 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.');
255
260
  return;
256
261
  }
257
- captureEvent('portal_login_step_advanced', {
258
- from_step: 'signup',
259
- to_step: 'authenticated',
260
- reason: 'signup_success',
262
+
263
+ setVerificationCode('');
264
+ setResendAvailableAt(result.resend_available_at ? new Date(result.resend_available_at).getTime() : Date.now() + 5 * 60 * 1000);
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,
261
293
  });
262
- captureEvent('portal_login_completed', { flow: 'signup' });
263
- if (onSuccess) onSuccess(); else router.refresh();
264
294
  } catch {
295
+ setError('Authentication failed. Please try again.');
265
296
  captureEvent('portal_login_failed', { step: 'signup', reason: 'network_error' });
266
- setError('Something went wrong. Please check your connection and try again.');
267
297
  } finally {
268
298
  setLoading(false);
269
299
  }
270
300
  };
271
301
 
272
- const goBack = () => {
273
- if (step === 'returning' || step === 'new') {
274
- const fromStep = step === 'returning' ? 'signin' : 'signup';
275
- captureEvent('portal_login_back_clicked', { from_step: fromStep, to_step: 'identifier' });
276
- }
302
+ const goBackToPhone = () => {
277
303
  setStep('identifier');
278
- setPassword('');
279
- setPasswordConfirm('');
280
- setAdditionalPhone('');
281
- setAdditionalEmail('');
282
- setIdentifiedWith(null);
304
+ setVerificationCode('');
305
+ setVerificationToken(null);
306
+ setFirstName('');
307
+ setLastName('');
308
+ setEmail('');
309
+ setResendAvailableAt(null);
283
310
  setError(null);
284
311
  };
285
312
 
286
- const identifierSummary = fullPhone ?? '';
287
-
288
- const headerTitle =
289
- step === 'identifier' ? 'Pricing & Booking Portal' :
290
- step === 'returning' ? (welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome back!') :
291
- (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
+ };
292
318
 
319
+ const headerTitle = step === 'identifier' ? 'Welcome' : step === 'verify' ? 'Verify your phone' : 'Last step';
293
320
  const headerSubtitle =
294
- step === 'identifier' ? 'Enter your phone number to get started.' :
295
- step === 'returning' ? 'Enter your password to sign in.' :
296
- (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.';
297
326
 
298
327
  return (
299
328
  <div>
300
- {/* Header */}
301
329
  <div className="flex items-start justify-between gap-4 mb-5">
302
330
  <div>
303
331
  <h3 className="text-lg font-normal text-primary">{headerTitle}</h3>
@@ -323,64 +351,69 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
323
351
  </div>
324
352
  )}
325
353
 
326
- {/* Step: identifier */}
327
- {step === 'identifier' && (
328
- <form onSubmit={handleContinue} className="space-y-3">
329
- <div>
330
- <label className={labelClass}>Phone number</label>
331
- {phoneInput(phoneValue, setPhoneValue, true)}
332
- </div>
333
- <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>
334
397
  <button
335
- type="submit"
336
- disabled={loading}
337
- 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"
338
402
  >
339
- {loading ? 'Looking you up…' : 'Continue'}
403
+ {secondsUntilResend > 0 ? `Request new code in ${formatCountdown(secondsUntilResend)}` : 'Request new code'}
340
404
  </button>
341
- </div>
342
- </form>
343
- )}
344
-
345
- {/* Step: returning user */}
346
- {step === 'returning' && (
347
- <form onSubmit={handleSignIn} className="space-y-3">
348
- <div>
349
- <label className={labelClass}>Password</label>
350
- <input
351
- type="password"
352
- value={password}
353
- onChange={(e) => setPassword(e.target.value)}
354
- placeholder="••••••••"
355
- className={inputClass}
356
- required
357
- autoComplete="current-password"
358
- />
359
- </div>
360
- <div className="pt-1">
361
405
  <button
362
- type="submit"
363
- disabled={loading}
364
- 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"
365
409
  >
366
- {loading ? 'Signing in…' : 'Sign in'}
410
+ Use a different phone number
367
411
  </button>
368
- </div>
369
- <button
370
- type="button"
371
- onClick={goBack}
372
- className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
373
- >
374
- ← {identifierSummary ? `Not ${identifierSummary}?` : 'Change phone number'}
375
- </button>
376
- </form>
377
- )}
412
+ </form>
413
+ )}
378
414
 
379
- {/* Step: new user / claim account */}
380
- {step === 'new' && (
381
- <form onSubmit={handleSignUp} className="space-y-3">
382
- {/* Only ask for name if we don't already know who they are */}
383
- {!welcomeName && (
415
+ {step === 'profile' && (
416
+ <form onSubmit={handleProfileSubmit} className="space-y-3">
384
417
  <div className="grid grid-cols-2 gap-3">
385
418
  <div>
386
419
  <label className={labelClass}>First name</label>
@@ -407,62 +440,37 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
407
440
  />
408
441
  </div>
409
442
  </div>
410
- )}
411
- {/* Collect the missing contact method so the business can reach out */}
412
- {identifiedWith === 'email' && (
413
- <div>
414
- <label className={labelClass}>Phone number</label>
415
- {phoneInput(additionalPhone, setAdditionalPhone, true)}
416
- </div>
417
- )}
418
- {identifiedWith === 'phone' && (
419
443
  <div>
420
444
  <label className={labelClass}>Email address</label>
421
- {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>
422
463
  </div>
423
- )}
424
- <div>
425
- <label className={labelClass}>Password</label>
426
- <input
427
- type="password"
428
- value={password}
429
- onChange={(e) => setPassword(e.target.value)}
430
- placeholder="••••••••"
431
- className={inputClass}
432
- required
433
- autoComplete="new-password"
434
- />
435
- </div>
436
- <div>
437
- <label className={labelClass}>Confirm password</label>
438
- <input
439
- type="password"
440
- value={passwordConfirm}
441
- onChange={(e) => setPasswordConfirm(e.target.value)}
442
- placeholder="••••••••"
443
- className={inputClass}
444
- required
445
- autoComplete="new-password"
446
- />
447
- </div>
448
- <div className="pt-1">
449
464
  <button
450
- type="submit"
451
- disabled={loading}
452
- 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"
453
468
  >
454
- {loading ? 'Creating account…' : 'Create account'}
469
+ Use a different phone number
455
470
  </button>
456
- </div>
457
- <button
458
- type="button"
459
- onClick={goBack}
460
- className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
461
- >
462
- ← Back
463
- </button>
464
- </form>
465
- )}
471
+ </form>
472
+ )}
473
+ </div>
466
474
  </div>
467
475
  );
468
476
  }