@pradip1995/segment-login-template 0.5.12 → 0.5.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/segment-login-template",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,6 +19,7 @@
19
19
  "./auth-server": "./src/auth-server.ts",
20
20
  "./account-profile-logic": "./src/account-profile-logic.ts",
21
21
  "./profile-phone-field": "./src/profile-phone-field.tsx",
22
+ "./profile-email-field": "./src/profile-email-field.tsx",
22
23
  "./google-auth-section": "./src/google-auth-section.tsx",
23
24
  "./otp-input": "./src/otp-input.tsx"
24
25
  },
@@ -44,6 +45,7 @@
44
45
  },
45
46
  "scripts": {
46
47
  "typecheck": "tsc --noEmit",
47
- "lint": "tsc --noEmit"
48
+ "lint": "tsc --noEmit",
49
+ "test": "node --experimental-strip-types --test src/*.test.ts"
48
50
  }
49
51
  }
@@ -9,10 +9,19 @@ import {
9
9
  parsePhoneParts,
10
10
  } from "@pradip1995/commerce-core/util/phone"
11
11
  import {
12
+ requestContactChange,
12
13
  sendRegistrationOtp,
14
+ verifyContactChange,
13
15
  verifyRegistrationOtp,
14
16
  } from "./auth-server"
15
17
  import { isNextRedirect, safeErrorMessage } from "./is-next-redirect"
18
+ import {
19
+ EMAIL_OTP_ERRORS,
20
+ isValidEmail,
21
+ normalizeEmail,
22
+ resolveEmailOtpFlow,
23
+ type EmailOtpFlow,
24
+ } from "./profile-email"
16
25
 
17
26
  export function customerPhoneToInputValue(phone?: string | null): string {
18
27
  if (!phone?.trim()) return ""
@@ -50,21 +59,27 @@ export function useAccountProfileLogic(
50
59
  const [message, setMessage] = useState<string | null>(null)
51
60
  const [phone, setPhone] = useState(() => customerPhoneToInputValue(customer.phone))
52
61
  const [phoneError, setPhoneError] = useState<string | null>(null)
62
+ const [email, setEmail] = useState(() => customer.email || "")
63
+ const [emailError, setEmailError] = useState<string | null>(null)
53
64
  const [otpOpen, setOtpOpen] = useState(false)
54
65
  const [otp, setOtp] = useState("")
55
66
  const [otpToken, setOtpToken] = useState<string | null>(null)
56
67
  const [otpType, setOtpType] = useState<"email_verification" | "phone_verification">(
57
68
  "email_verification"
58
69
  )
70
+ const [otpFlow, setOtpFlow] = useState<EmailOtpFlow>("verify_existing")
59
71
  const [otpError, setOtpError] = useState<string | null>(null)
60
72
  const [otpPending, setOtpPending] = useState(false)
61
73
  const [phoneVerified, setPhoneVerified] = useState(() =>
62
74
  readVerified(customer, "phone_verified")
63
75
  )
64
- const [emailVerified] = useState(() => readVerified(customer, "email_verified"))
76
+ const [emailVerified, setEmailVerified] = useState(() =>
77
+ readVerified(customer, "email_verified")
78
+ )
65
79
 
66
80
  const phoneParts = parsePhoneParts(phone)
67
81
  const phoneReady = isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
82
+ const emailReady = isValidEmail(email)
68
83
 
69
84
  async function saveProfile(_prev: unknown, formData: FormData) {
70
85
  try {
@@ -127,6 +142,30 @@ export function useAccountProfileLogic(
127
142
  if (type === "phone_verification") {
128
143
  const saved = await ensurePhoneSavedForVerification()
129
144
  if (!saved) return
145
+ setOtpFlow("verify_existing")
146
+ }
147
+
148
+ if (type === "email_verification") {
149
+ const resolved = resolveEmailOtpFlow({
150
+ currentEmail: customer.email,
151
+ draftEmail: email,
152
+ })
153
+ if (!resolved.ok) {
154
+ setEmailError(EMAIL_OTP_ERRORS[resolved.reason])
155
+ return
156
+ }
157
+ setOtpFlow(resolved.flow)
158
+ const res =
159
+ resolved.flow === "change_contact"
160
+ ? await requestContactChange({ email: resolved.email })
161
+ : await sendRegistrationOtp(customer.id, type)
162
+ if (!res?.success) {
163
+ throw new Error(res?.error || "Failed to send code")
164
+ }
165
+ setOtpToken(res.token ?? null)
166
+ setOtp("")
167
+ setOtpOpen(true)
168
+ return
130
169
  }
131
170
 
132
171
  const res = await sendRegistrationOtp(customer.id, type)
@@ -143,6 +182,9 @@ export function useAccountProfileLogic(
143
182
  if (type === "phone_verification") {
144
183
  setPhoneError(msg)
145
184
  }
185
+ if (type === "email_verification") {
186
+ setEmailError(msg)
187
+ }
146
188
  } finally {
147
189
  setOtpPending(false)
148
190
  }
@@ -153,19 +195,33 @@ export function useAccountProfileLogic(
153
195
  setOtpPending(true)
154
196
  setOtpError(null)
155
197
  try {
156
- const res = await verifyRegistrationOtp({
157
- otpToken: otpToken || "",
158
- code: otp,
159
- countryCode,
160
- skipRedirect: true,
161
- })
162
- if (!res?.success) {
163
- throw new Error(res?.error || "Invalid code")
198
+ if (otpType === "email_verification" && otpFlow === "change_contact") {
199
+ const res = await verifyContactChange({
200
+ otpToken: otpToken || "",
201
+ code: otp,
202
+ })
203
+ if (!res?.success) {
204
+ throw new Error(res?.error || "Invalid code")
205
+ }
206
+ setEmailVerified(true)
207
+ } else {
208
+ const res = await verifyRegistrationOtp({
209
+ otpToken: otpToken || "",
210
+ code: otp,
211
+ countryCode,
212
+ skipRedirect: true,
213
+ })
214
+ if (!res?.success) {
215
+ throw new Error(res?.error || "Invalid code")
216
+ }
217
+ if (otpType === "phone_verification") {
218
+ setPhoneVerified(true)
219
+ }
220
+ if (otpType === "email_verification") {
221
+ setEmailVerified(true)
222
+ }
164
223
  }
165
224
  setOtpOpen(false)
166
- if (otpType === "phone_verification") {
167
- setPhoneVerified(true)
168
- }
169
225
  setMessage("Verification successful.")
170
226
  window.location.reload()
171
227
  } catch (err) {
@@ -187,6 +243,14 @@ export function useAccountProfileLogic(
187
243
  }
188
244
  }
189
245
 
246
+ function handleEmailChange(value: string) {
247
+ setEmail(value)
248
+ setEmailError(null)
249
+ if (normalizeEmail(value) !== normalizeEmail(customer.email)) {
250
+ setEmailVerified(false)
251
+ }
252
+ }
253
+
190
254
  const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(" ")
191
255
 
192
256
  return {
@@ -196,6 +260,10 @@ export function useAccountProfileLogic(
196
260
  phoneReady,
197
261
  phoneError,
198
262
  phoneVerified,
263
+ email,
264
+ handleEmailChange,
265
+ emailReady,
266
+ emailError,
199
267
  emailVerified,
200
268
  message,
201
269
  state,
@@ -5,6 +5,7 @@ import type { OtpVerificationComponent } from "./otp-component"
5
5
  import OtpVerificationModal from "./otp-verification-modal"
6
6
  import { useAccountProfileLogic } from "./account-profile-logic"
7
7
  import ProfilePhoneField from "./profile-phone-field"
8
+ import ProfileEmailField from "./profile-email-field"
8
9
  import { acct } from "./account-theme"
9
10
 
10
11
  export default function AccountProfile({
@@ -27,31 +28,16 @@ export default function AccountProfile({
27
28
 
28
29
  <form action={profile.formAction} className={`${acct.card} p-6 space-y-4`}>
29
30
  <Field label="Full name" name="full_name" defaultValue={profile.fullName} />
30
- <div>
31
- <label className="block text-xs font-semibold uppercase tracking-wider text-muted mb-1.5">
32
- Email
33
- </label>
34
- <div className="flex flex-wrap items-center gap-3">
35
- <input
36
- value={customer.email}
37
- readOnly
38
- className="flex-1 min-w-[200px] border border-cart-border rounded-none px-3 py-2.5 text-sm bg-surface-muted text-muted"
39
- />
40
- {!profile.emailVerified && (
41
- <button
42
- type="button"
43
- onClick={() => profile.sendVerifyOtp("email_verification")}
44
- disabled={profile.otpPending}
45
- className={`${acct.btnOutline} !py-2 !px-3`}
46
- >
47
- Verify email
48
- </button>
49
- )}
50
- {profile.emailVerified && (
51
- <span className="text-xs text-green-600 font-medium">Verified</span>
52
- )}
53
- </div>
54
- </div>
31
+ <ProfileEmailField
32
+ email={profile.email}
33
+ onEmailChange={profile.handleEmailChange}
34
+ emailVerified={profile.emailVerified}
35
+ emailError={profile.emailError}
36
+ verifyPending={profile.otpPending}
37
+ onVerify={() => profile.sendVerifyOtp("email_verification")}
38
+ verifyButtonClassName={`${acct.btnOutline} !py-2 !px-3`}
39
+ editButtonClassName={`${acct.btnOutline} !py-2 !px-3`}
40
+ />
55
41
 
56
42
  <ProfilePhoneField
57
43
  phone={profile.phone}
@@ -85,7 +71,7 @@ export default function AccountProfile({
85
71
  ? "Verify your email"
86
72
  : "Verify your phone"
87
73
  }
88
- description={`Enter the code sent to your ${profile.otpType === "email_verification" ? "email" : "phone"}.`}
74
+ description={`Enter the code sent to your ${profile.otpType === "email_verification" ? profile.email || "email" : "phone"}.`}
89
75
  otp={profile.otp}
90
76
  onOtpChange={profile.setOtp}
91
77
  error={profile.otpError}
@@ -9,7 +9,7 @@ import {
9
9
  verifyCustomerOTP,
10
10
  } from "@pradip1995/commerce-core/data/customer-registration"
11
11
  import { transferCart } from "@pradip1995/commerce-core/data/customer"
12
- import { getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
12
+ import { getAuthHeaders, getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
13
13
  import { sendOTP, verifyOTP } from "@pradip1995/commerce-core/data/guest"
14
14
  import { getGoogleOAuthCallbackUrl } from "@pradip1995/commerce-core/util/google-oauth"
15
15
  import { revalidateTag } from "next/cache"
@@ -64,6 +64,13 @@ function getHeaders() {
64
64
  }
65
65
  }
66
66
 
67
+ async function getAuthenticatedHeaders() {
68
+ return {
69
+ ...getHeaders(),
70
+ ...(await getAuthHeaders()),
71
+ }
72
+ }
73
+
67
74
  function getBaseUrl() {
68
75
  return process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
69
76
  }
@@ -296,6 +303,74 @@ export async function sendRegistrationOtp(
296
303
  }
297
304
  }
298
305
 
306
+ export async function requestContactChange(input: { email?: string; phone?: string }) {
307
+ const body = input.email
308
+ ? { email: input.email.trim().toLowerCase() }
309
+ : { phone: input.phone }
310
+
311
+ const response = await fetch(`${getBaseUrl()}/store/customers/me/contact`, {
312
+ method: "POST",
313
+ headers: await getAuthenticatedHeaders(),
314
+ body: JSON.stringify(body),
315
+ cache: "no-store",
316
+ })
317
+
318
+ if (!response.ok) {
319
+ const err = (await response.json().catch(() => ({}))) as { message?: string }
320
+ const message = err.message || "Failed to send verification code"
321
+ if (message.includes("Channel configuration not found")) {
322
+ return {
323
+ success: false as const,
324
+ error:
325
+ "Verification is not configured on the backend. Rebuild and restart the Medusa server with customer-registration OTP settings.",
326
+ }
327
+ }
328
+ return { success: false as const, error: message }
329
+ }
330
+
331
+ const data = (await response.json()) as { token?: string; expires_at?: string }
332
+
333
+ return {
334
+ success: true as const,
335
+ token: data.token,
336
+ }
337
+ }
338
+
339
+ export async function verifyContactChange(input: { otpToken: string; code: string }) {
340
+ const response = await fetch(`${getBaseUrl()}/store/customers/me/contact/verify`, {
341
+ method: "POST",
342
+ headers: await getAuthenticatedHeaders(),
343
+ body: JSON.stringify({
344
+ token: input.otpToken,
345
+ code: input.code,
346
+ }),
347
+ cache: "no-store",
348
+ })
349
+
350
+ const data = (await response.json().catch(() => ({}))) as {
351
+ message?: string
352
+ token?: string | null
353
+ }
354
+
355
+ if (!response.ok) {
356
+ return {
357
+ success: false as const,
358
+ error: data.message || "Invalid verification code",
359
+ }
360
+ }
361
+
362
+ if (data.token) {
363
+ await setAuthToken(data.token)
364
+ }
365
+
366
+ const customerCacheTag = await getCacheTag("customers")
367
+ if (customerCacheTag) {
368
+ revalidateTag(customerCacheTag)
369
+ }
370
+
371
+ return { success: true as const }
372
+ }
373
+
299
374
  export async function initiateGoogleAuth(countryCode?: string) {
300
375
  try {
301
376
  const backendUrl = getBaseUrl()
@@ -0,0 +1,116 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { isEmailFieldLocked, isValidEmail, profileEmailAction } from "./profile-email"
5
+
6
+ export type ProfileEmailFieldProps = {
7
+ email: string
8
+ onEmailChange: (value: string) => void
9
+ emailVerified: boolean
10
+ emailError: string | null
11
+ verifyPending: boolean
12
+ onVerify: () => void
13
+ label?: string
14
+ wrapperClassName?: string
15
+ labelClassName?: string
16
+ rowClassName?: string
17
+ inputClassName?: string
18
+ verifyButtonClassName?: string
19
+ editButtonClassName?: string
20
+ verifiedClassName?: string
21
+ errorClassName?: string
22
+ hintClassName?: string
23
+ }
24
+
25
+ export default function ProfileEmailField({
26
+ email,
27
+ onEmailChange,
28
+ emailVerified,
29
+ emailError,
30
+ verifyPending,
31
+ onVerify,
32
+ label = "Email",
33
+ wrapperClassName = "",
34
+ labelClassName = "block text-xs font-semibold uppercase tracking-wider text-muted mb-1.5",
35
+ rowClassName = "flex flex-wrap items-center gap-3",
36
+ inputClassName = "flex-1 min-w-[200px] border border-cart-border rounded-none px-3 py-2.5 text-sm bg-surface text-heading focus:outline-none focus:border-brand-accent",
37
+ verifyButtonClassName = "",
38
+ editButtonClassName = "",
39
+ verifiedClassName = "text-xs text-green-600 font-medium",
40
+ errorClassName = "text-sm text-red-600 mt-1.5",
41
+ hintClassName = "text-xs text-muted mt-1.5",
42
+ }: ProfileEmailFieldProps) {
43
+ const [editing, setEditing] = useState(false)
44
+ const locked = isEmailFieldLocked({ emailVerified, editing })
45
+ const emailReady = isValidEmail(email)
46
+ const action = profileEmailAction({ emailVerified, editing, emailReady })
47
+
48
+ useEffect(() => {
49
+ if (emailVerified) setEditing(false)
50
+ }, [emailVerified])
51
+
52
+ return (
53
+ <div className={wrapperClassName}>
54
+ <label className={labelClassName} htmlFor="account-profile-email">
55
+ {label}
56
+ </label>
57
+ <div className={rowClassName}>
58
+ <input
59
+ id="account-profile-email"
60
+ name="email"
61
+ type="email"
62
+ value={email}
63
+ onChange={(event) => onEmailChange(event.target.value)}
64
+ readOnly={locked}
65
+ placeholder="Enter your email"
66
+ autoComplete="email"
67
+ className={`${inputClassName}${locked ? " bg-surface-muted text-muted" : ""}`}
68
+ data-testid="account-profile-email"
69
+ />
70
+ {action === "edit" ? (
71
+ <>
72
+ <span className={verifiedClassName}>Verified</span>
73
+ <button
74
+ type="button"
75
+ onClick={() => setEditing(true)}
76
+ className={editButtonClassName || verifyButtonClassName}
77
+ data-testid="account-profile-edit-email"
78
+ >
79
+ Edit
80
+ </button>
81
+ </>
82
+ ) : null}
83
+ {action === "verified" ? <span className={verifiedClassName}>Verified</span> : null}
84
+ {action === "verify" ? (
85
+ <button
86
+ type="button"
87
+ onClick={onVerify}
88
+ disabled={verifyPending}
89
+ className={verifyButtonClassName}
90
+ data-testid="account-profile-verify-email"
91
+ >
92
+ {verifyPending ? "Sending…" : "Verify email"}
93
+ </button>
94
+ ) : null}
95
+ </div>
96
+ {emailError ? (
97
+ <p className={errorClassName} role="alert">
98
+ {emailError}
99
+ </p>
100
+ ) : null}
101
+ {action === "edit" ? (
102
+ <p className={hintClassName}>
103
+ Tap <strong>Edit</strong> to change this email.
104
+ </p>
105
+ ) : action === "verify" ? (
106
+ <p className={hintClassName}>
107
+ Tap <strong>Verify email</strong> to receive an OTP at this address.
108
+ </p>
109
+ ) : editing ? (
110
+ <p className={hintClassName}>
111
+ Update the email, then verify it again.
112
+ </p>
113
+ ) : null}
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,182 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert/strict"
3
+ import {
4
+ EMAIL_OTP_ERRORS,
5
+ isEmailFieldLocked,
6
+ isValidEmail,
7
+ normalizeEmail,
8
+ profileEmailAction,
9
+ resolveEmailOtpFlow,
10
+ type ProfileEmailAction,
11
+ type ResolveEmailOtpFlowResult,
12
+ } from "./profile-email.ts"
13
+
14
+ const NORMALIZE_CASES: { name: string; value: string | null | undefined; want: string }[] = [
15
+ { name: "trims and lowercases a typed email", value: " Jay@Empire.COM ", want: "jay@empire.com" },
16
+ { name: "empty string stays empty", value: "", want: "" },
17
+ { name: "whitespace-only becomes empty", value: " ", want: "" },
18
+ { name: "null becomes empty", value: null, want: "" },
19
+ { name: "undefined becomes empty", value: undefined, want: "" },
20
+ ]
21
+
22
+ const VALID_CASES: { name: string; value: string | null | undefined; want: boolean }[] = [
23
+ { name: "accepts a normal email", value: "jay@empire.com", want: true },
24
+ { name: "accepts mixed-case with spaces", value: " Jay@Empire.COM ", want: true },
25
+ { name: "rejects empty", value: "", want: false },
26
+ { name: "rejects missing at-sign", value: "jayempire.com", want: false },
27
+ { name: "rejects missing domain", value: "jay@", want: false },
28
+ { name: "rejects phone-only customer empty email", value: null, want: false },
29
+ ]
30
+
31
+ const FLOW_CASES: {
32
+ name: string
33
+ currentEmail: string | null | undefined
34
+ draftEmail: string
35
+ want: ResolveEmailOtpFlowResult
36
+ }[] = [
37
+ {
38
+ name: "phone-only user adding an email uses contact change",
39
+ currentEmail: "",
40
+ draftEmail: "jay@empire.com",
41
+ want: { ok: true, flow: "change_contact", email: "jay@empire.com" },
42
+ },
43
+ {
44
+ name: "missing current email uses contact change",
45
+ currentEmail: null,
46
+ draftEmail: "jay@empire.com",
47
+ want: { ok: true, flow: "change_contact", email: "jay@empire.com" },
48
+ },
49
+ {
50
+ name: "same unverified email verifies the existing address",
51
+ currentEmail: "jay@empire.com",
52
+ draftEmail: "Jay@Empire.com",
53
+ want: { ok: true, flow: "verify_existing", email: "jay@empire.com" },
54
+ },
55
+ {
56
+ name: "changing to a different email uses contact change",
57
+ currentEmail: "old@empire.com",
58
+ draftEmail: "new@empire.com",
59
+ want: { ok: true, flow: "change_contact", email: "new@empire.com" },
60
+ },
61
+ {
62
+ name: "empty draft is empty",
63
+ currentEmail: "",
64
+ draftEmail: " ",
65
+ want: { ok: false, reason: "empty" },
66
+ },
67
+ {
68
+ name: "malformed draft is invalid",
69
+ currentEmail: "",
70
+ draftEmail: "not-an-email",
71
+ want: { ok: false, reason: "invalid" },
72
+ },
73
+ ]
74
+
75
+ const LOCK_CASES: { name: string; emailVerified: boolean; editing: boolean; want: boolean }[] = [
76
+ { name: "verified and not editing is locked", emailVerified: true, editing: false, want: true },
77
+ { name: "verified while editing is unlocked so the user can type", emailVerified: true, editing: true, want: false },
78
+ { name: "unverified empty email is never locked", emailVerified: false, editing: false, want: false },
79
+ { name: "unverified stays unlocked even if editing flag is set", emailVerified: false, editing: true, want: false },
80
+ ]
81
+
82
+ const ACTION_CASES: {
83
+ name: string
84
+ emailVerified: boolean
85
+ editing: boolean
86
+ emailReady: boolean
87
+ want: ProfileEmailAction
88
+ }[] = [
89
+ {
90
+ name: "phone-only empty field shows no verify button yet",
91
+ emailVerified: false,
92
+ editing: false,
93
+ emailReady: false,
94
+ want: "none",
95
+ },
96
+ {
97
+ name: "typed valid unverified email shows verify",
98
+ emailVerified: false,
99
+ editing: false,
100
+ emailReady: true,
101
+ want: "verify",
102
+ },
103
+ {
104
+ name: "verified email shows edit",
105
+ emailVerified: true,
106
+ editing: false,
107
+ emailReady: true,
108
+ want: "edit",
109
+ },
110
+ {
111
+ name: "editing a verified email keeps the verified label until the value changes",
112
+ emailVerified: true,
113
+ editing: true,
114
+ emailReady: true,
115
+ want: "verified",
116
+ },
117
+ ]
118
+
119
+ describe("normalizeEmail", () => {
120
+ for (const tc of NORMALIZE_CASES) {
121
+ it(tc.name, () => {
122
+ assert.equal(normalizeEmail(tc.value), tc.want)
123
+ })
124
+ }
125
+ })
126
+
127
+ describe("isValidEmail", () => {
128
+ for (const tc of VALID_CASES) {
129
+ it(tc.name, () => {
130
+ assert.equal(isValidEmail(tc.value), tc.want)
131
+ })
132
+ }
133
+ })
134
+
135
+ describe("resolveEmailOtpFlow", () => {
136
+ for (const tc of FLOW_CASES) {
137
+ it(tc.name, () => {
138
+ assert.deepEqual(
139
+ resolveEmailOtpFlow({ currentEmail: tc.currentEmail, draftEmail: tc.draftEmail }),
140
+ tc.want
141
+ )
142
+ })
143
+ }
144
+ })
145
+
146
+ describe("isEmailFieldLocked", () => {
147
+ for (const tc of LOCK_CASES) {
148
+ it(tc.name, () => {
149
+ assert.equal(
150
+ isEmailFieldLocked({ emailVerified: tc.emailVerified, editing: tc.editing }),
151
+ tc.want
152
+ )
153
+ })
154
+ }
155
+ })
156
+
157
+ describe("profileEmailAction", () => {
158
+ for (const tc of ACTION_CASES) {
159
+ it(tc.name, () => {
160
+ assert.equal(
161
+ profileEmailAction({
162
+ emailVerified: tc.emailVerified,
163
+ editing: tc.editing,
164
+ emailReady: tc.emailReady,
165
+ }),
166
+ tc.want
167
+ )
168
+ })
169
+ }
170
+ })
171
+
172
+ describe("EMAIL_OTP_ERRORS", () => {
173
+ const CASES = [
174
+ { name: "empty copy", reason: "empty" as const, want: "Please enter an email address before verifying." },
175
+ { name: "invalid copy", reason: "invalid" as const, want: "Please enter a valid email address." },
176
+ ]
177
+ for (const tc of CASES) {
178
+ it(tc.name, () => {
179
+ assert.equal(EMAIL_OTP_ERRORS[tc.reason], tc.want)
180
+ })
181
+ }
182
+ })
@@ -0,0 +1,54 @@
1
+ export const EMAIL_REGEX = /^\S+@\S+\.\S+$/
2
+
3
+ export const EMAIL_OTP_ERRORS = {
4
+ empty: "Please enter an email address before verifying.",
5
+ invalid: "Please enter a valid email address.",
6
+ } as const
7
+
8
+ export function normalizeEmail(value: string | null | undefined): string {
9
+ return (value || "").trim().toLowerCase()
10
+ }
11
+
12
+ export function isValidEmail(value: string | null | undefined): boolean {
13
+ return EMAIL_REGEX.test(normalizeEmail(value))
14
+ }
15
+
16
+ export type EmailOtpFlow = "verify_existing" | "change_contact"
17
+
18
+ export type ResolveEmailOtpFlowResult =
19
+ | { ok: true; flow: EmailOtpFlow; email: string }
20
+ | { ok: false; reason: "empty" | "invalid" }
21
+
22
+ export function resolveEmailOtpFlow(input: {
23
+ currentEmail: string | null | undefined
24
+ draftEmail: string | null | undefined
25
+ }): ResolveEmailOtpFlowResult {
26
+ const email = normalizeEmail(input.draftEmail)
27
+ if (!email) return { ok: false, reason: "empty" }
28
+ if (!isValidEmail(email)) return { ok: false, reason: "invalid" }
29
+ const current = normalizeEmail(input.currentEmail)
30
+ if (current && email === current) {
31
+ return { ok: true, flow: "verify_existing", email }
32
+ }
33
+ return { ok: true, flow: "change_contact", email }
34
+ }
35
+
36
+ export function isEmailFieldLocked(input: {
37
+ emailVerified: boolean
38
+ editing: boolean
39
+ }): boolean {
40
+ return input.emailVerified && !input.editing
41
+ }
42
+
43
+ export type ProfileEmailAction = "verify" | "edit" | "verified" | "none"
44
+
45
+ export function profileEmailAction(input: {
46
+ emailVerified: boolean
47
+ editing: boolean
48
+ emailReady: boolean
49
+ }): ProfileEmailAction {
50
+ if (isEmailFieldLocked(input)) return "edit"
51
+ if (input.emailVerified) return "verified"
52
+ if (input.emailReady) return "verify"
53
+ return "none"
54
+ }