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.
- 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 +280 -272
- 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
|
@@ -1,12 +1,13 @@
|
|
|
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';
|
|
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
|
-
|
|
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 [
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
153
|
+
const res = await fetch('/api/consumer/send_code', {
|
|
122
154
|
method: 'POST',
|
|
123
155
|
headers: { 'Content-Type': 'application/json' },
|
|
124
|
-
|
|
156
|
+
credentials: 'include',
|
|
157
|
+
body: JSON.stringify({ phone: fullPhone }),
|
|
125
158
|
});
|
|
126
|
-
const result = await res.json().catch(() => ({
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
182
|
+
const handleVerifyCode = async (e: React.FormEvent) => {
|
|
173
183
|
e.preventDefault();
|
|
174
|
-
|
|
175
|
-
|
|
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/
|
|
195
|
+
const res = await fetch('/api/consumer/verify_code', {
|
|
179
196
|
method: 'POST',
|
|
180
197
|
headers: { 'Content-Type': 'application/json' },
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
304
|
+
setVerificationCode('');
|
|
305
|
+
setVerificationToken(null);
|
|
306
|
+
setFirstName('');
|
|
307
|
+
setLastName('');
|
|
308
|
+
setEmail('');
|
|
309
|
+
setResendAvailableAt(null);
|
|
283
310
|
setError(null);
|
|
284
311
|
};
|
|
285
312
|
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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'
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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="
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
{
|
|
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="
|
|
363
|
-
|
|
364
|
-
className="w-full
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={goBackToPhone}
|
|
408
|
+
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
365
409
|
>
|
|
366
|
-
|
|
410
|
+
← Use a different phone number
|
|
367
411
|
</button>
|
|
368
|
-
</
|
|
369
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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="
|
|
451
|
-
|
|
452
|
-
className="w-full
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={goBackToPhone}
|
|
467
|
+
className="w-full text-center text-sm text-tertiary hover:text-primary transition-colors"
|
|
453
468
|
>
|
|
454
|
-
|
|
469
|
+
← Use a different phone number
|
|
455
470
|
</button>
|
|
456
|
-
</
|
|
457
|
-
|
|
458
|
-
|
|
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
|
}
|