@pradip1995/segment-login-template 0.5.7 → 0.5.9

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.7",
3
+ "version": "0.5.9",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -17,9 +17,15 @@
17
17
  "./commerce-auth-otp-modal": "./src/commerce-auth-otp-modal.tsx",
18
18
  "./otp-component": "./src/otp-component.ts",
19
19
  "./auth-server": "./src/auth-server.ts",
20
+ "./account-profile-logic": "./src/account-profile-logic.ts",
21
+ "./profile-phone-field": "./src/profile-phone-field.tsx",
20
22
  "./google-auth-section": "./src/google-auth-section.tsx",
21
23
  "./otp-input": "./src/otp-input.tsx"
22
24
  },
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "tsc --noEmit"
28
+ },
23
29
  "peerDependencies": {
24
30
  "@pradip1995/commerce-auth": "^4.0.0",
25
31
  "@pradip1995/commerce-core": "^4.0.0",
@@ -39,9 +45,5 @@
39
45
  "@types/react": "^19",
40
46
  "react": "19.0.3",
41
47
  "typescript": "^5.7.2"
42
- },
43
- "scripts": {
44
- "typecheck": "tsc --noEmit",
45
- "lint": "tsc --noEmit"
46
48
  }
47
- }
49
+ }
@@ -4,7 +4,7 @@ import type { HttpTypes } from "@medusajs/types"
4
4
  import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
5
5
  import { formatPrice } from "@pradip1995/segment-primitives/format-price"
6
6
  import { logoutGuest } from "@pradip1995/commerce-core/data/guest"
7
- import { auth } from "./account-theme"
7
+ import { acct } from "./account-theme"
8
8
 
9
9
  export default function AccountGuestOrders({
10
10
  orders,
@@ -62,8 +62,8 @@ export default function AccountGuestOrders({
62
62
  {safeOrders.length === 0 ? (
63
63
  <div className="border border-cart-border bg-surface p-8 text-center">
64
64
  <p className="text-sm text-muted mb-4">No guest orders found for this session.</p>
65
- <LocalizedLink href="/store" className={`${auth.btnPrimary} ${auth.btnShine} inline-flex`}>
66
- <span className="relative z-[1]">Continue shopping</span>
65
+ <LocalizedLink href="/store" className={acct.btnPrimary}>
66
+ Continue shopping
67
67
  </LocalizedLink>
68
68
  </div>
69
69
  ) : (
@@ -0,0 +1,210 @@
1
+ "use client"
2
+
3
+ import { useActionState, useState } from "react"
4
+ import type { HttpTypes } from "@medusajs/types"
5
+ import { updateCustomer } from "@pradip1995/commerce-core/data/customer"
6
+ import {
7
+ combinePhoneParts,
8
+ isValidPhoneParts,
9
+ parsePhoneParts,
10
+ } from "@pradip1995/commerce-core/util/phone"
11
+ import {
12
+ sendRegistrationOtp,
13
+ verifyRegistrationOtp,
14
+ } from "./auth-server"
15
+ import { isNextRedirect, safeErrorMessage } from "./is-next-redirect"
16
+
17
+ export function customerPhoneToInputValue(phone?: string | null): string {
18
+ if (!phone?.trim()) return ""
19
+ const parts = parsePhoneParts(phone)
20
+ return combinePhoneParts(parts.dialCode, parts.localNumber)
21
+ }
22
+
23
+ export function phoneDigits(value: string): string {
24
+ return value.replace(/\D/g, "")
25
+ }
26
+
27
+ export function phoneToE164(value: string): string {
28
+ const parts = parsePhoneParts(value)
29
+ const local = parts.localNumber.replace(/\D/g, "")
30
+ const dial = parts.dialCode.replace(/\D/g, "")
31
+ if (!local) return ""
32
+ return `+${dial}${local}`
33
+ }
34
+
35
+ function readVerified(
36
+ customer: HttpTypes.StoreCustomer,
37
+ field: "email_verified" | "phone_verified"
38
+ ): boolean {
39
+ const direct = (customer as Record<string, unknown>)[field]
40
+ if (direct === true || direct === "true") return true
41
+ const meta = customer.metadata as Record<string, unknown> | undefined
42
+ const fromMeta = meta?.[field]
43
+ return fromMeta === true || fromMeta === "true"
44
+ }
45
+
46
+ export function useAccountProfileLogic(
47
+ customer: HttpTypes.StoreCustomer,
48
+ countryCode: string
49
+ ) {
50
+ const [message, setMessage] = useState<string | null>(null)
51
+ const [phone, setPhone] = useState(() => customerPhoneToInputValue(customer.phone))
52
+ const [phoneError, setPhoneError] = useState<string | null>(null)
53
+ const [otpOpen, setOtpOpen] = useState(false)
54
+ const [otp, setOtp] = useState("")
55
+ const [otpToken, setOtpToken] = useState<string | null>(null)
56
+ const [otpType, setOtpType] = useState<"email_verification" | "phone_verification">(
57
+ "email_verification"
58
+ )
59
+ const [otpError, setOtpError] = useState<string | null>(null)
60
+ const [otpPending, setOtpPending] = useState(false)
61
+ const [phoneVerified, setPhoneVerified] = useState(() =>
62
+ readVerified(customer, "phone_verified")
63
+ )
64
+ const [emailVerified] = useState(() => readVerified(customer, "email_verified"))
65
+
66
+ const phoneParts = parsePhoneParts(phone)
67
+ const phoneReady = isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
68
+
69
+ async function saveProfile(_prev: unknown, formData: FormData) {
70
+ try {
71
+ const fullName = String(formData.get("full_name") || "").trim()
72
+ const [first_name, ...rest] = fullName.split(/\s+/)
73
+ const e164 = phoneToE164(phone)
74
+ await updateCustomer({
75
+ first_name: first_name || "",
76
+ last_name: rest.join(" ") || ".",
77
+ phone: e164 || undefined,
78
+ })
79
+ if (e164 && phoneDigits(e164) !== phoneDigits(customer.phone || "")) {
80
+ setPhoneVerified(false)
81
+ }
82
+ return { ok: true as const, error: null as string | null }
83
+ } catch (err) {
84
+ if (isNextRedirect(err)) throw err
85
+ return {
86
+ ok: false as const,
87
+ error: safeErrorMessage(err, "Failed to update profile"),
88
+ }
89
+ }
90
+ }
91
+
92
+ const [state, formAction, pending] = useActionState(saveProfile, {
93
+ ok: false as const,
94
+ error: null as string | null,
95
+ })
96
+
97
+ async function ensurePhoneSavedForVerification(): Promise<boolean> {
98
+ setPhoneError(null)
99
+ if (!phoneReady) {
100
+ setPhoneError("Please enter a valid mobile number before verifying.")
101
+ return false
102
+ }
103
+
104
+ const e164 = phoneToE164(phone)
105
+ if (phoneDigits(e164) === phoneDigits(customer.phone || "")) {
106
+ return true
107
+ }
108
+
109
+ try {
110
+ await updateCustomer({ phone: e164 })
111
+ return true
112
+ } catch (err) {
113
+ if (isNextRedirect(err)) throw err
114
+ setPhoneError(
115
+ safeErrorMessage(err, "Failed to save mobile number. Please try again.")
116
+ )
117
+ return false
118
+ }
119
+ }
120
+
121
+ async function sendVerifyOtp(type: "email_verification" | "phone_verification") {
122
+ setOtpType(type)
123
+ setOtpPending(true)
124
+ setOtpError(null)
125
+
126
+ try {
127
+ if (type === "phone_verification") {
128
+ const saved = await ensurePhoneSavedForVerification()
129
+ if (!saved) return
130
+ }
131
+
132
+ const res = await sendRegistrationOtp(customer.id, type)
133
+ if (!res.success) throw new Error(res.error || "Failed to send code")
134
+ setOtpToken(res.token ?? null)
135
+ setOtp("")
136
+ setOtpOpen(true)
137
+ } catch (err) {
138
+ if (isNextRedirect(err)) throw err
139
+ const msg = safeErrorMessage(err, "Failed to send code")
140
+ setOtpError(msg)
141
+ if (type === "phone_verification") {
142
+ setPhoneError(msg)
143
+ }
144
+ } finally {
145
+ setOtpPending(false)
146
+ }
147
+ }
148
+
149
+ async function verifyOtp() {
150
+ if (otp.length < 6) return
151
+ setOtpPending(true)
152
+ setOtpError(null)
153
+ try {
154
+ const res = await verifyRegistrationOtp({
155
+ otpToken: otpToken || "",
156
+ code: otp,
157
+ countryCode,
158
+ skipRedirect: true,
159
+ })
160
+ if (!res.success) throw new Error(res.error || "Invalid code")
161
+ setOtpOpen(false)
162
+ if (otpType === "phone_verification") {
163
+ setPhoneVerified(true)
164
+ }
165
+ setMessage("Verification successful.")
166
+ window.location.reload()
167
+ } catch (err) {
168
+ if (isNextRedirect(err)) throw err
169
+ setOtpError(safeErrorMessage(err, "Verification failed"))
170
+ setOtpPending(false)
171
+ }
172
+ }
173
+
174
+ function handlePhoneChange(value: string) {
175
+ setPhone(value)
176
+ setPhoneError(null)
177
+ const nextE164 = phoneToE164(value)
178
+ if (
179
+ nextE164 &&
180
+ phoneDigits(nextE164) !== phoneDigits(customer.phone || "")
181
+ ) {
182
+ setPhoneVerified(false)
183
+ }
184
+ }
185
+
186
+ const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(" ")
187
+
188
+ return {
189
+ fullName,
190
+ phone,
191
+ handlePhoneChange,
192
+ phoneReady,
193
+ phoneError,
194
+ phoneVerified,
195
+ emailVerified,
196
+ message,
197
+ state,
198
+ formAction,
199
+ pending,
200
+ otpOpen,
201
+ setOtpOpen,
202
+ otp,
203
+ setOtp,
204
+ otpType,
205
+ otpError,
206
+ otpPending,
207
+ sendVerifyOtp,
208
+ verifyOtp,
209
+ }
210
+ }
@@ -1,16 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { useActionState, useState } from "react"
4
3
  import type { HttpTypes } from "@medusajs/types"
5
- import { updateCustomer } from "@pradip1995/commerce-core/data/customer"
6
- import {
7
- sendRegistrationOtp,
8
- verifyRegistrationOtp,
9
- } from "./auth-server"
10
4
  import type { OtpVerificationComponent } from "./otp-component"
11
5
  import OtpVerificationModal from "./otp-verification-modal"
6
+ import { useAccountProfileLogic } from "./account-profile-logic"
7
+ import ProfilePhoneField from "./profile-phone-field"
12
8
  import { acct } from "./account-theme"
13
- import { isNextRedirect, safeErrorMessage } from "./is-next-redirect"
14
9
 
15
10
  export default function AccountProfile({
16
11
  customer,
@@ -21,80 +16,7 @@ export default function AccountProfile({
21
16
  countryCode: string
22
17
  OtpComponent?: OtpVerificationComponent
23
18
  }) {
24
- const [message, setMessage] = useState<string | null>(null)
25
- const [otpOpen, setOtpOpen] = useState(false)
26
- const [otp, setOtp] = useState("")
27
- const [otpToken, setOtpToken] = useState<string | null>(null)
28
- const [otpType, setOtpType] = useState<"email_verification" | "phone_verification">(
29
- "email_verification"
30
- )
31
- const [otpError, setOtpError] = useState<string | null>(null)
32
- const [otpPending, setOtpPending] = useState(false)
33
-
34
- async function saveProfile(_prev: unknown, formData: FormData) {
35
- try {
36
- const fullName = String(formData.get("full_name") || "").trim()
37
- const [first_name, ...rest] = fullName.split(/\s+/)
38
- await updateCustomer({
39
- first_name: first_name || "",
40
- last_name: rest.join(" ") || ".",
41
- phone: String(formData.get("phone") || "").replace(/[^\d+]/g, "") || undefined,
42
- })
43
- return { ok: true as const, error: null as string | null }
44
- } catch (err) {
45
- if (isNextRedirect(err)) throw err
46
- return {
47
- ok: false as const,
48
- error: safeErrorMessage(err, "Failed to update profile"),
49
- }
50
- }
51
- }
52
-
53
- const [state, formAction, pending] = useActionState(saveProfile, { ok: false, error: null })
54
-
55
- async function sendVerifyOtp(type: "email_verification" | "phone_verification") {
56
- setOtpType(type)
57
- setOtpPending(true)
58
- setOtpError(null)
59
- try {
60
- const res = await sendRegistrationOtp(customer.id, type)
61
- if (!res.success) throw new Error(res.error || "Failed to send code")
62
- setOtpToken(res.token ?? null)
63
- setOtp("")
64
- setOtpOpen(true)
65
- } catch (err) {
66
- if (isNextRedirect(err)) throw err
67
- setOtpError(safeErrorMessage(err, "Failed to send code"))
68
- } finally {
69
- setOtpPending(false)
70
- }
71
- }
72
-
73
- async function verifyOtp() {
74
- if (otp.length < 6) return
75
- setOtpPending(true)
76
- setOtpError(null)
77
- try {
78
- const res = await verifyRegistrationOtp({
79
- otpToken: otpToken || "",
80
- code: otp,
81
- countryCode,
82
- skipRedirect: true,
83
- })
84
- if (!res.success) throw new Error(res.error || "Invalid code")
85
- setOtpOpen(false)
86
- setMessage("Verification successful.")
87
- window.location.reload()
88
- } catch (err) {
89
- if (isNextRedirect(err)) throw err
90
- setOtpError(safeErrorMessage(err, "Verification failed"))
91
- setOtpPending(false)
92
- }
93
- }
94
-
95
- const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(" ")
96
- const emailVerified = (customer as { email_verified?: boolean }).email_verified === true
97
- const phoneVerified = (customer as { phone_verified?: boolean }).phone_verified === true
19
+ const profile = useAccountProfileLogic(customer, countryCode)
98
20
 
99
21
  return (
100
22
  <div className="space-y-8">
@@ -103,8 +25,8 @@ export default function AccountProfile({
103
25
  <p className={`text-sm ${acct.muted}`}>Update your name and contact information.</p>
104
26
  </div>
105
27
 
106
- <form action={formAction} className={`${acct.card} p-6 space-y-4`}>
107
- <Field label="Full name" name="full_name" defaultValue={fullName} />
28
+ <form action={profile.formAction} className={`${acct.card} p-6 space-y-4`}>
29
+ <Field label="Full name" name="full_name" defaultValue={profile.fullName} />
108
30
  <div>
109
31
  <label className="block text-xs font-semibold uppercase tracking-wider text-muted mb-1.5">
110
32
  Email
@@ -115,62 +37,61 @@ export default function AccountProfile({
115
37
  readOnly
116
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"
117
39
  />
118
- {!emailVerified && (
40
+ {!profile.emailVerified && (
119
41
  <button
120
42
  type="button"
121
- onClick={() => sendVerifyOtp("email_verification")}
122
- disabled={otpPending}
43
+ onClick={() => profile.sendVerifyOtp("email_verification")}
44
+ disabled={profile.otpPending}
123
45
  className={`${acct.btnOutline} !py-2 !px-3`}
124
46
  >
125
47
  Verify email
126
48
  </button>
127
49
  )}
128
- {emailVerified && (
129
- <span className="text-xs text-green-600 font-medium">Verified</span>
130
- )}
131
- </div>
132
- </div>
133
- <div>
134
- <Field label="Phone" name="phone" type="tel" defaultValue={customer.phone || ""} />
135
- <div className="mt-2 flex items-center gap-3">
136
- {!phoneVerified && customer.phone && (
137
- <button
138
- type="button"
139
- onClick={() => sendVerifyOtp("phone_verification")}
140
- disabled={otpPending}
141
- className={`${acct.btnOutline} !py-2 !px-3`}
142
- >
143
- Verify phone
144
- </button>
145
- )}
146
- {phoneVerified && customer.phone && (
50
+ {profile.emailVerified && (
147
51
  <span className="text-xs text-green-600 font-medium">Verified</span>
148
52
  )}
149
53
  </div>
150
54
  </div>
151
55
 
152
- {(state.error || message) && (
153
- <p className={`text-sm ${state.ok || message ? "text-green-600" : "text-red-600"}`}>
154
- {message || state.error}
56
+ <ProfilePhoneField
57
+ phone={profile.phone}
58
+ onPhoneChange={profile.handlePhoneChange}
59
+ phoneVerified={profile.phoneVerified}
60
+ phoneReady={profile.phoneReady}
61
+ phoneError={profile.phoneError}
62
+ verifyPending={profile.otpPending}
63
+ onVerify={() => profile.sendVerifyOtp("phone_verification")}
64
+ verifyButtonClassName={`${acct.btnOutline} !py-2 !px-3`}
65
+ />
66
+
67
+ {(profile.state.error || profile.message) && (
68
+ <p
69
+ className={`text-sm ${profile.state.ok || profile.message ? "text-green-600" : "text-red-600"}`}
70
+ >
71
+ {profile.message || profile.state.error}
155
72
  </p>
156
73
  )}
157
74
 
158
- <button type="submit" disabled={pending} className={acct.btnPrimary}>
159
- {pending ? "Saving…" : "Save changes"}
75
+ <button type="submit" disabled={profile.pending} className={acct.btnPrimary}>
76
+ {profile.pending ? "Saving…" : "Save changes"}
160
77
  </button>
161
78
  </form>
162
79
 
163
80
  <OtpComponent
164
- open={otpOpen}
165
- title={otpType === "email_verification" ? "Verify your email" : "Verify your phone"}
166
- description={`Enter the code sent to your ${otpType === "email_verification" ? "email" : "phone"}.`}
167
- otp={otp}
168
- onOtpChange={setOtp}
169
- error={otpError}
170
- pending={otpPending}
171
- onVerify={verifyOtp}
172
- onResend={() => sendVerifyOtp(otpType)}
173
- onClose={() => setOtpOpen(false)}
81
+ open={profile.otpOpen}
82
+ title={
83
+ profile.otpType === "email_verification"
84
+ ? "Verify your email"
85
+ : "Verify your phone"
86
+ }
87
+ description={`Enter the code sent to your ${profile.otpType === "email_verification" ? "email" : "phone"}.`}
88
+ otp={profile.otp}
89
+ onOtpChange={profile.setOtp}
90
+ error={profile.otpError}
91
+ pending={profile.otpPending}
92
+ onVerify={profile.verifyOtp}
93
+ onResend={() => profile.sendVerifyOtp(profile.otpType)}
94
+ onClose={() => profile.setOtpOpen(false)}
174
95
  />
175
96
  </div>
176
97
  )
@@ -51,7 +51,7 @@ export const auth = {
51
51
  input:
52
52
  "w-full px-0 py-3 text-sm text-heading bg-transparent border-0 border-b-2 border-cart-border rounded-none placeholder:text-muted/50 focus:outline-none focus:border-brand-primary focus:ring-0 transition-colors duration-200",
53
53
  btnPrimary:
54
- "group relative w-full py-3.5 bg-brand-primary text-inverse text-[11px] font-bold uppercase tracking-[0.28em] shadow-[0_8px_28px_-6px_rgba(0,0,0,0.25)] hover:bg-brand-accent hover:shadow-[0_12px_32px_-6px_rgba(0,0,0,0.3)] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden active:scale-[0.98]",
54
+ "group relative w-full inline-flex items-center justify-center py-3.5 bg-brand-primary text-inverse text-[11px] font-bold uppercase tracking-[0.28em] shadow-[0_8px_28px_-6px_rgba(0,0,0,0.25)] hover:bg-brand-accent hover:shadow-[0_12px_32px_-6px_rgba(0,0,0,0.3)] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden active:scale-[0.98]",
55
55
  btnShine:
56
56
  "before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/15 before:to-transparent before:-translate-x-full hover:before:translate-x-full before:transition-transform before:duration-700",
57
57
  btnOutline:
@@ -0,0 +1,83 @@
1
+ "use client"
2
+
3
+ import { SplitPhoneInput } from "@pradip1995/commerce-core/components/split-phone-input"
4
+
5
+ export type ProfilePhoneFieldProps = {
6
+ phone: string
7
+ onPhoneChange: (value: string) => void
8
+ phoneVerified: boolean
9
+ phoneReady: boolean
10
+ phoneError: string | null
11
+ verifyPending: boolean
12
+ onVerify: () => void
13
+ label?: string
14
+ wrapperClassName?: string
15
+ labelClassName?: string
16
+ rowClassName?: string
17
+ verifyButtonClassName?: string
18
+ verifiedClassName?: string
19
+ errorClassName?: string
20
+ hintClassName?: string
21
+ phoneInputClassName?: string
22
+ numberInputId?: string
23
+ }
24
+
25
+ export default function ProfilePhoneField({
26
+ phone,
27
+ onPhoneChange,
28
+ phoneVerified,
29
+ phoneReady,
30
+ phoneError,
31
+ verifyPending,
32
+ onVerify,
33
+ label = "Phone",
34
+ wrapperClassName = "",
35
+ labelClassName = "text-xs font-semibold uppercase tracking-wider text-muted mb-1.5 block",
36
+ rowClassName = "flex flex-wrap items-center gap-3",
37
+ verifyButtonClassName = "",
38
+ verifiedClassName = "text-xs text-green-600 font-medium",
39
+ errorClassName = "text-sm text-red-600 mt-1.5",
40
+ hintClassName = "text-xs text-muted mt-1.5",
41
+ phoneInputClassName = "",
42
+ numberInputId = "account-profile-phone",
43
+ }: ProfilePhoneFieldProps) {
44
+ return (
45
+ <div className={wrapperClassName}>
46
+ <span className={labelClassName}>{label}</span>
47
+ <div className={rowClassName}>
48
+ <div className={`flex-1 min-w-[200px] ${phoneInputClassName}`}>
49
+ <SplitPhoneInput
50
+ value={phone}
51
+ onChange={onPhoneChange}
52
+ label="Mobile number"
53
+ numberInputId={numberInputId}
54
+ data-testid="account-profile-phone"
55
+ />
56
+ </div>
57
+ {phoneVerified ? (
58
+ <span className={verifiedClassName}>Verified</span>
59
+ ) : phoneReady ? (
60
+ <button
61
+ type="button"
62
+ onClick={onVerify}
63
+ disabled={verifyPending}
64
+ className={verifyButtonClassName}
65
+ data-testid="account-profile-verify-phone"
66
+ >
67
+ {verifyPending ? "Sending…" : "Verify phone"}
68
+ </button>
69
+ ) : null}
70
+ </div>
71
+ {phoneError ? (
72
+ <p className={errorClassName} role="alert">
73
+ {phoneError}
74
+ </p>
75
+ ) : null}
76
+ {!phoneVerified && phoneReady ? (
77
+ <p className={hintClassName}>
78
+ Tap <strong>Verify phone</strong> to receive an OTP on this number.
79
+ </p>
80
+ ) : null}
81
+ </div>
82
+ )
83
+ }