@pradip1995/segment-login-template 0.4.2 → 0.5.2
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/package.json +10 -7
- package/src/account-guest-orders.tsx +41 -12
- package/src/account-payment-methods.tsx +7 -0
- package/src/account-theme.ts +64 -1
- package/src/auth-server.ts +23 -7
- package/src/forgot-password-form.tsx +44 -26
- package/src/google-auth-section.tsx +8 -10
- package/src/guest-order-modal.tsx +224 -0
- package/src/login-brand-panel.tsx +158 -0
- package/src/login-form.tsx +211 -147
- package/src/otp-unified-form.tsx +340 -0
- package/src/payment-methods-actions.ts +16 -4
- package/src/register-form.tsx +77 -57
- package/src/segment.tsx +181 -48
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { SplitPhoneInput } from "@pradip1995/commerce-core/components/split-phone-input"
|
|
5
|
+
import {
|
|
6
|
+
formatPhoneDisplay,
|
|
7
|
+
isValidPhoneParts,
|
|
8
|
+
parsePhoneParts,
|
|
9
|
+
toE164Phone,
|
|
10
|
+
} from "@pradip1995/commerce-core/util/phone"
|
|
11
|
+
import { sendAuthOtp, verifyAuthOtpAndLogin } from "./auth-server"
|
|
12
|
+
import GoogleAuthSection from "./google-auth-section"
|
|
13
|
+
import OtpInput from "./otp-input"
|
|
14
|
+
import { auth } from "./account-theme"
|
|
15
|
+
import { AuthDivider, AuthField, Ornament } from "./login-form"
|
|
16
|
+
|
|
17
|
+
type AuthMethod = "email_auth" | "phone_auth"
|
|
18
|
+
type OtpStep = "identifier" | "verify"
|
|
19
|
+
|
|
20
|
+
const RESEND_COOLDOWN_SEC = 30
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sahsha-style single-screen OTP auth: Google + Mobile/Email OTP.
|
|
24
|
+
* New users finish signup in the same verify step (first name).
|
|
25
|
+
*/
|
|
26
|
+
export default function OtpUnifiedForm({
|
|
27
|
+
countryCode,
|
|
28
|
+
onTrackGuest,
|
|
29
|
+
}: {
|
|
30
|
+
countryCode: string
|
|
31
|
+
onTrackGuest?: () => void
|
|
32
|
+
}) {
|
|
33
|
+
const [authMethod, setAuthMethod] = useState<AuthMethod>("phone_auth")
|
|
34
|
+
const [identifier, setIdentifier] = useState("")
|
|
35
|
+
const [otpToken, setOtpToken] = useState<string | null>(null)
|
|
36
|
+
const [otp, setOtp] = useState("")
|
|
37
|
+
const [step, setStep] = useState<OtpStep>("identifier")
|
|
38
|
+
const [error, setError] = useState<string | null>(null)
|
|
39
|
+
const [sending, setSending] = useState(false)
|
|
40
|
+
const [verifying, setVerifying] = useState(false)
|
|
41
|
+
const [isNewUser, setIsNewUser] = useState(false)
|
|
42
|
+
const [firstName, setFirstName] = useState("")
|
|
43
|
+
const [resendIn, setResendIn] = useState(0)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (resendIn <= 0) return
|
|
47
|
+
const timer = window.setTimeout(() => setResendIn((s) => s - 1), 1000)
|
|
48
|
+
return () => window.clearTimeout(timer)
|
|
49
|
+
}, [resendIn])
|
|
50
|
+
|
|
51
|
+
const phoneParts = parsePhoneParts(identifier)
|
|
52
|
+
const phoneReady =
|
|
53
|
+
authMethod === "phone_auth" &&
|
|
54
|
+
isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
|
|
55
|
+
const identifierReady =
|
|
56
|
+
authMethod === "email_auth" ? Boolean(identifier.trim()) : phoneReady
|
|
57
|
+
|
|
58
|
+
async function handleSendOtp(options?: { resend?: boolean }) {
|
|
59
|
+
if (!identifierReady || sending) return
|
|
60
|
+
setSending(true)
|
|
61
|
+
setError(null)
|
|
62
|
+
|
|
63
|
+
// Advance immediately so the user is not stuck on "Sending…" during SMTP/SMS.
|
|
64
|
+
if (!options?.resend) {
|
|
65
|
+
setOtp("")
|
|
66
|
+
setFirstName("")
|
|
67
|
+
setOtpToken(null)
|
|
68
|
+
setStep("verify")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const res = await sendAuthOtp(
|
|
73
|
+
authMethod === "email_auth"
|
|
74
|
+
? { email: identifier.trim(), type: "email_auth" }
|
|
75
|
+
: { phone: toE164Phone(identifier), type: "phone_auth" }
|
|
76
|
+
)
|
|
77
|
+
if (!res.success) throw new Error(res.error)
|
|
78
|
+
setOtpToken(res.token ?? null)
|
|
79
|
+
setIsNewUser(res.isNewUser ?? false)
|
|
80
|
+
setOtp("")
|
|
81
|
+
setResendIn(RESEND_COOLDOWN_SEC)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const message = err instanceof Error ? err.message : "Failed to send code"
|
|
84
|
+
setError(message)
|
|
85
|
+
if (!options?.resend) {
|
|
86
|
+
setStep("identifier")
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
setSending(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleVerify() {
|
|
94
|
+
if (!otp || otp.length < 6 || !otpToken || verifying || sending) return
|
|
95
|
+
setVerifying(true)
|
|
96
|
+
setError(null)
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
if (isNewUser && !firstName.trim()) {
|
|
100
|
+
throw new Error("Please enter your first name to finish registration")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await verifyAuthOtpAndLogin({
|
|
104
|
+
token: otpToken,
|
|
105
|
+
code: otp,
|
|
106
|
+
countryCode,
|
|
107
|
+
first_name: isNewUser ? firstName.trim() : undefined,
|
|
108
|
+
last_name: isNewUser ? "." : undefined,
|
|
109
|
+
})
|
|
110
|
+
if (result && !result.success) {
|
|
111
|
+
throw new Error(result.error || "Verification failed")
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (isNextRedirect(err)) throw err
|
|
115
|
+
setError(err instanceof Error ? err.message : "Verification failed")
|
|
116
|
+
setVerifying(false)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resetToIdentifier() {
|
|
121
|
+
setStep("identifier")
|
|
122
|
+
setOtp("")
|
|
123
|
+
setOtpToken(null)
|
|
124
|
+
setError(null)
|
|
125
|
+
setIsNewUser(false)
|
|
126
|
+
setFirstName("")
|
|
127
|
+
setSending(false)
|
|
128
|
+
setVerifying(false)
|
|
129
|
+
setResendIn(0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const identifierDisplay =
|
|
133
|
+
authMethod === "phone_auth" ? formatPhoneDisplay(identifier) || identifier : identifier
|
|
134
|
+
const channelLabel = authMethod === "email_auth" ? "email" : "number"
|
|
135
|
+
const verifyHint = sending
|
|
136
|
+
? `Sending code to ${identifierDisplay}…`
|
|
137
|
+
: `Enter the 6-digit code sent to ${identifierDisplay}`
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="w-full flex flex-col space-y-5" data-testid="login-page">
|
|
141
|
+
<div className="text-center lg:hidden">
|
|
142
|
+
<Ornament />
|
|
143
|
+
<h2 className={auth.mobileTitle}>Sign in</h2>
|
|
144
|
+
<p className="mt-2 text-[10px] text-muted uppercase tracking-[0.24em]">
|
|
145
|
+
Select your login mode to continue
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{step === "identifier" ? (
|
|
150
|
+
<>
|
|
151
|
+
<GoogleAuthSection countryCode={countryCode} />
|
|
152
|
+
<AuthDivider label="Or continue with email / mobile" />
|
|
153
|
+
|
|
154
|
+
<div className="flex gap-2 p-1 bg-surface-muted/80">
|
|
155
|
+
<ModeButton
|
|
156
|
+
active={authMethod === "phone_auth"}
|
|
157
|
+
onClick={() => {
|
|
158
|
+
setAuthMethod("phone_auth")
|
|
159
|
+
setIdentifier("")
|
|
160
|
+
setError(null)
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
Mobile
|
|
164
|
+
</ModeButton>
|
|
165
|
+
<ModeButton
|
|
166
|
+
active={authMethod === "email_auth"}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
setAuthMethod("email_auth")
|
|
169
|
+
setIdentifier("")
|
|
170
|
+
setError(null)
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
Email
|
|
174
|
+
</ModeButton>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{authMethod === "phone_auth" ? (
|
|
178
|
+
<SplitPhoneInput
|
|
179
|
+
value={identifier}
|
|
180
|
+
onChange={setIdentifier}
|
|
181
|
+
label="Mobile number"
|
|
182
|
+
numberInputId="account-login-phone"
|
|
183
|
+
data-testid="account-login-phone"
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<AuthField
|
|
187
|
+
label="Email"
|
|
188
|
+
name="identifier"
|
|
189
|
+
type="email"
|
|
190
|
+
value={identifier}
|
|
191
|
+
onChange={(e) => setIdentifier(e.target.value)}
|
|
192
|
+
autoComplete="email"
|
|
193
|
+
placeholder="Enter your email"
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{error && (
|
|
198
|
+
<p className="text-sm text-brand-sale" role="alert">
|
|
199
|
+
{error}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
disabled={sending || !identifierReady}
|
|
206
|
+
onClick={() => void handleSendOtp()}
|
|
207
|
+
className={`${auth.btnPrimary} ${auth.btnShine}`}
|
|
208
|
+
>
|
|
209
|
+
<span className="relative z-[1]">{sending ? "Continue…" : "Continue"}</span>
|
|
210
|
+
</button>
|
|
211
|
+
</>
|
|
212
|
+
) : (
|
|
213
|
+
<div className="space-y-5">
|
|
214
|
+
<div className="text-center">
|
|
215
|
+
<h3 className="font-heading text-lg font-bold text-heading uppercase tracking-[0.08em]">
|
|
216
|
+
Verify OTP
|
|
217
|
+
</h3>
|
|
218
|
+
<p className="mt-2 text-sm text-muted" aria-live="polite">
|
|
219
|
+
{sending ? (
|
|
220
|
+
verifyHint
|
|
221
|
+
) : (
|
|
222
|
+
<>
|
|
223
|
+
Enter the 6-digit code sent to{" "}
|
|
224
|
+
<span className="text-heading font-medium">{identifierDisplay}</span>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{isNewUser && (
|
|
231
|
+
<AuthField
|
|
232
|
+
label="First name"
|
|
233
|
+
name="first_name"
|
|
234
|
+
value={firstName}
|
|
235
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
236
|
+
placeholder="Your first name"
|
|
237
|
+
required
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
<OtpInput
|
|
242
|
+
value={otp}
|
|
243
|
+
onChange={setOtp}
|
|
244
|
+
autoFocus={!isNewUser && !sending && Boolean(otpToken)}
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
{error && (
|
|
248
|
+
<p className="text-sm text-brand-sale text-center" role="alert">
|
|
249
|
+
{error}
|
|
250
|
+
</p>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
disabled={
|
|
256
|
+
sending ||
|
|
257
|
+
verifying ||
|
|
258
|
+
!otpToken ||
|
|
259
|
+
otp.length < 6 ||
|
|
260
|
+
(isNewUser && !firstName.trim())
|
|
261
|
+
}
|
|
262
|
+
onClick={() => void handleVerify()}
|
|
263
|
+
className={`${auth.btnPrimary} ${auth.btnShine}`}
|
|
264
|
+
>
|
|
265
|
+
<span className="relative z-[1]">
|
|
266
|
+
{sending
|
|
267
|
+
? "Waiting for code…"
|
|
268
|
+
: verifying
|
|
269
|
+
? "Verifying…"
|
|
270
|
+
: isNewUser
|
|
271
|
+
? "Verify & create account"
|
|
272
|
+
: "Verify & sign in"}
|
|
273
|
+
</span>
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
<div className="flex items-center justify-between gap-3 text-xs">
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={resetToIdentifier}
|
|
280
|
+
disabled={verifying}
|
|
281
|
+
className="text-muted hover:text-brand-accent transition-colors disabled:opacity-50"
|
|
282
|
+
>
|
|
283
|
+
Change {channelLabel}
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
disabled={sending || verifying || resendIn > 0}
|
|
288
|
+
onClick={() => void handleSendOtp({ resend: true })}
|
|
289
|
+
className="text-muted hover:text-brand-accent transition-colors disabled:opacity-50"
|
|
290
|
+
>
|
|
291
|
+
{sending
|
|
292
|
+
? "Resending…"
|
|
293
|
+
: resendIn > 0
|
|
294
|
+
? `Resend in ${resendIn}s`
|
|
295
|
+
: "Resend OTP"}
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{onTrackGuest && step === "identifier" && (
|
|
302
|
+
<div className="pt-4 border-t border-brand-primary/10">
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={onTrackGuest}
|
|
306
|
+
className={auth.guestCta}
|
|
307
|
+
data-testid="track-guest-order-button"
|
|
308
|
+
>
|
|
309
|
+
Track guest order
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ModeButton({
|
|
318
|
+
active,
|
|
319
|
+
onClick,
|
|
320
|
+
children,
|
|
321
|
+
}: {
|
|
322
|
+
active: boolean
|
|
323
|
+
onClick: () => void
|
|
324
|
+
children: React.ReactNode
|
|
325
|
+
}) {
|
|
326
|
+
return (
|
|
327
|
+
<button type="button" onClick={onClick} className={active ? auth.modeActive : auth.modeIdle}>
|
|
328
|
+
{children}
|
|
329
|
+
</button>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isNextRedirect(err: unknown) {
|
|
334
|
+
return (
|
|
335
|
+
typeof err === "object" &&
|
|
336
|
+
err !== null &&
|
|
337
|
+
"digest" in err &&
|
|
338
|
+
String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
|
|
339
|
+
)
|
|
340
|
+
}
|
|
@@ -109,6 +109,7 @@ function parseBackendError(body: unknown, status: number): string {
|
|
|
109
109
|
|
|
110
110
|
const data = body as {
|
|
111
111
|
message?: unknown
|
|
112
|
+
error?: unknown
|
|
112
113
|
errors?: Array<{ message?: string; path?: string[]; field?: string }>
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -124,12 +125,23 @@ function parseBackendError(body: unknown, status: number): string {
|
|
|
124
125
|
if (details.length) return details.join("; ")
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
const primary =
|
|
129
|
+
typeof data.message === "string" && data.message.trim()
|
|
130
|
+
? data.message.trim()
|
|
131
|
+
: typeof data.error === "string" && data.error.trim()
|
|
132
|
+
? data.error.trim()
|
|
133
|
+
: ""
|
|
134
|
+
|
|
135
|
+
if (primary) {
|
|
136
|
+
if (/^invalid request( data)?\.?$/i.test(primary)) {
|
|
130
137
|
return "Invalid payment details. Check required fields and try again."
|
|
131
138
|
}
|
|
132
|
-
|
|
139
|
+
if (/^failed to delete payment detail\.?$/i.test(primary)) {
|
|
140
|
+
const detail =
|
|
141
|
+
typeof data.error === "string" && data.error.trim() ? data.error.trim() : ""
|
|
142
|
+
if (detail) return detail
|
|
143
|
+
}
|
|
144
|
+
return primary
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
return `Request failed (${status})`
|
package/src/register-form.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import GoogleAuthSection from "./google-auth-section"
|
|
10
10
|
import OtpVerificationModal from "./otp-verification-modal"
|
|
11
11
|
import type { OtpVerificationComponent } from "./otp-component"
|
|
12
|
+
import { auth } from "./account-theme"
|
|
13
|
+
import { AuthDivider, AuthField, Ornament } from "./login-form"
|
|
12
14
|
|
|
13
15
|
type VerificationMethod = "email_verification" | "phone_verification"
|
|
14
16
|
type RegistrationStep = "form" | "otp" | "success"
|
|
@@ -37,6 +39,7 @@ export default function RegisterForm({
|
|
|
37
39
|
const [error, setError] = useState<string | null>(null)
|
|
38
40
|
const [pending, setPending] = useState(false)
|
|
39
41
|
const [showOtpModal, setShowOtpModal] = useState(false)
|
|
42
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
40
43
|
|
|
41
44
|
function updateField(key: keyof typeof form, value: string) {
|
|
42
45
|
setForm((prev) => ({ ...prev, [key]: value }))
|
|
@@ -143,21 +146,22 @@ export default function RegisterForm({
|
|
|
143
146
|
verificationMethod === "email_verification" ? form.email : form.phone
|
|
144
147
|
|
|
145
148
|
return (
|
|
146
|
-
<div className="space-y-6">
|
|
149
|
+
<div className="space-y-6" data-testid="register-page">
|
|
150
|
+
<div className="text-center lg:hidden">
|
|
151
|
+
<Ornament />
|
|
152
|
+
<h2 className={auth.mobileTitle}>Create account</h2>
|
|
153
|
+
<p className="mt-2 text-[10px] text-muted uppercase tracking-[0.24em]">
|
|
154
|
+
Join our collection
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
147
158
|
<GoogleAuthSection countryCode={countryCode} />
|
|
148
159
|
|
|
149
|
-
<
|
|
150
|
-
<div className="absolute inset-0 flex items-center">
|
|
151
|
-
<div className="w-full border-t border-cart-border" />
|
|
152
|
-
</div>
|
|
153
|
-
<div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
|
|
154
|
-
<span className="bg-page-bg px-3 text-muted">Or register with email</span>
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
160
|
+
<AuthDivider label="Or register with email" />
|
|
157
161
|
|
|
158
162
|
{step === "form" && (
|
|
159
163
|
<>
|
|
160
|
-
<div className="flex gap-2 p-1 bg-surface-muted
|
|
164
|
+
<div className="flex gap-2 p-1 bg-surface-muted/80">
|
|
161
165
|
<VerificationToggle
|
|
162
166
|
active={verificationMethod === "email_verification"}
|
|
163
167
|
onClick={() => setVerificationMethod("email_verification")}
|
|
@@ -179,39 +183,67 @@ export default function RegisterForm({
|
|
|
179
183
|
</p>
|
|
180
184
|
|
|
181
185
|
<form onSubmit={handleRegister} className="space-y-4">
|
|
182
|
-
<
|
|
186
|
+
<AuthField
|
|
183
187
|
label="Full name"
|
|
184
188
|
value={form.full_name}
|
|
185
189
|
onChange={(e) => updateField("full_name", e.target.value)}
|
|
186
190
|
autoComplete="name"
|
|
191
|
+
placeholder="Your full name"
|
|
187
192
|
/>
|
|
188
|
-
<
|
|
193
|
+
<AuthField
|
|
189
194
|
label="Email"
|
|
190
195
|
type="email"
|
|
191
196
|
value={form.email}
|
|
192
197
|
onChange={(e) => updateField("email", e.target.value)}
|
|
193
198
|
autoComplete="email"
|
|
199
|
+
placeholder="you@example.com"
|
|
194
200
|
/>
|
|
195
|
-
<
|
|
201
|
+
<AuthField
|
|
196
202
|
label="Phone"
|
|
197
203
|
type="tel"
|
|
198
204
|
value={form.phone}
|
|
199
205
|
onChange={(e) => updateField("phone", e.target.value)}
|
|
200
206
|
autoComplete="tel"
|
|
201
207
|
required={verificationMethod === "phone_verification"}
|
|
208
|
+
placeholder="Phone number"
|
|
202
209
|
/>
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
210
|
+
<div>
|
|
211
|
+
<span className={`${auth.fieldLabel} mb-1.5 block`}>Password</span>
|
|
212
|
+
<div className="relative">
|
|
213
|
+
<input
|
|
214
|
+
type={showPassword ? "text" : "password"}
|
|
215
|
+
value={form.password}
|
|
216
|
+
onChange={(e) => updateField("password", e.target.value)}
|
|
217
|
+
autoComplete="new-password"
|
|
218
|
+
required
|
|
219
|
+
placeholder="Create a password"
|
|
220
|
+
className={`${auth.input} pr-10`}
|
|
221
|
+
/>
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={() => setShowPassword((v) => !v)}
|
|
225
|
+
className="absolute inset-y-0 right-0 flex items-center text-muted hover:text-heading transition-colors"
|
|
226
|
+
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
227
|
+
>
|
|
228
|
+
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{error && (
|
|
234
|
+
<p className="text-sm text-brand-sale" role="alert">
|
|
235
|
+
{error}
|
|
236
|
+
</p>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
<button
|
|
240
|
+
type="submit"
|
|
241
|
+
disabled={pending}
|
|
242
|
+
className={`${auth.btnPrimary} ${auth.btnShine}`}
|
|
243
|
+
>
|
|
244
|
+
<span className="relative z-[1]">
|
|
245
|
+
{pending ? "Creating account…" : "Create account"}
|
|
246
|
+
</span>
|
|
215
247
|
</button>
|
|
216
248
|
</form>
|
|
217
249
|
</>
|
|
@@ -225,7 +257,11 @@ export default function RegisterForm({
|
|
|
225
257
|
|
|
226
258
|
<p className="text-sm text-center text-muted">
|
|
227
259
|
Already have an account?{" "}
|
|
228
|
-
<button
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={onLogin}
|
|
263
|
+
className="text-heading font-semibold hover:text-brand-accent transition-colors underline underline-offset-2"
|
|
264
|
+
>
|
|
229
265
|
Sign in
|
|
230
266
|
</button>
|
|
231
267
|
</p>
|
|
@@ -282,43 +318,27 @@ function VerificationToggle({
|
|
|
282
318
|
<button
|
|
283
319
|
type="button"
|
|
284
320
|
onClick={onClick}
|
|
285
|
-
className={
|
|
286
|
-
active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
|
|
287
|
-
}`}
|
|
321
|
+
className={active ? auth.modeActive : auth.modeIdle}
|
|
288
322
|
>
|
|
289
323
|
{children}
|
|
290
324
|
</button>
|
|
291
325
|
)
|
|
292
326
|
}
|
|
293
327
|
|
|
294
|
-
function
|
|
295
|
-
label,
|
|
296
|
-
type = "text",
|
|
297
|
-
value,
|
|
298
|
-
onChange,
|
|
299
|
-
autoComplete,
|
|
300
|
-
required = true,
|
|
301
|
-
}: {
|
|
302
|
-
label: string
|
|
303
|
-
type?: string
|
|
304
|
-
value: string
|
|
305
|
-
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
306
|
-
autoComplete?: string
|
|
307
|
-
required?: boolean
|
|
308
|
-
}) {
|
|
328
|
+
function EyeIcon() {
|
|
309
329
|
return (
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
/>
|
|
322
|
-
</
|
|
330
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
|
331
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
332
|
+
<circle cx="12" cy="12" r="3" />
|
|
333
|
+
</svg>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function EyeOffIcon() {
|
|
338
|
+
return (
|
|
339
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
|
340
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
341
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
342
|
+
</svg>
|
|
323
343
|
)
|
|
324
344
|
}
|