@pradip1995/segment-login-popup 0.1.0

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 ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pradip1995/segment-login-popup",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "src/segment.css"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./manifest": "./src/manifest.ts",
17
+ "./provider": "./src/login-popup-provider.tsx",
18
+ "./required": "./src/login-required-modal.tsx",
19
+ "./types": "./src/types.ts"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit",
23
+ "lint": "tsc --noEmit"
24
+ },
25
+ "peerDependencies": {
26
+ "@pradip1995/commerce-auth": "^4.0.0",
27
+ "@pradip1995/commerce-core": "^4.0.0",
28
+ "@pradip1995/plugin-sdk": "^0.2.0",
29
+ "react": ">=19",
30
+ "react-dom": ">=19",
31
+ "next": ">=15"
32
+ },
33
+ "dependencies": {
34
+ "@pradip1995/commerce-auth": "^4.0.0",
35
+ "@pradip1995/segment-login-template": "^0.5.2",
36
+ "@pradip1995/segment-primitives": "^0.4.0",
37
+ "@pradip1995/segment-tokens": "^0.3.7",
38
+ "@pradip1995/commerce-core": "^4.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@pradip1995/plugin-sdk": "^0.2.0",
42
+ "@types/react": "^19",
43
+ "react": "19.0.3",
44
+ "typescript": "^5.7.2"
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
3
+ export { default as LoginPopup } from "./login-popup"
4
+ export {
5
+ LoginPopupProvider,
6
+ useLoginPopup,
7
+ } from "./login-popup-provider"
8
+ export { default as LoginRequiredModal } from "./login-required-modal"
9
+ export {
10
+ DEFAULT_LOGIN_POPUP_FEATURE_CARDS,
11
+ normalizeLoginPopupConfig,
12
+ } from "./types"
@@ -0,0 +1,149 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react"
13
+ import { usePathname } from "next/navigation"
14
+ import {
15
+ normalizeLoginPopupConfig,
16
+ type LoginPopupConfig,
17
+ } from "./types"
18
+
19
+ type LoginPopupContextValue = {
20
+ isPopupOpen: boolean
21
+ dismissPopup: () => void
22
+ openPopup: () => void
23
+ popupConfig: ReturnType<typeof normalizeLoginPopupConfig>
24
+ }
25
+
26
+ const LoginPopupContext = createContext<LoginPopupContextValue | null>(null)
27
+
28
+ function isBlockedPath(pathname: string | null): boolean {
29
+ if (!pathname) return false
30
+ return (
31
+ pathname.includes("/account") ||
32
+ pathname.includes("/checkout") ||
33
+ (pathname.includes("/order/") && pathname.includes("/confirmed"))
34
+ )
35
+ }
36
+
37
+ function hasSeenPopup(storageKey: string): boolean {
38
+ if (typeof window === "undefined") return true
39
+ try {
40
+ return localStorage.getItem(storageKey) === "1"
41
+ } catch {
42
+ return true
43
+ }
44
+ }
45
+
46
+ function isCookieBannerPending(): boolean {
47
+ return document.documentElement.getAttribute("data-cookie-banner") === "pending"
48
+ }
49
+
50
+ export function LoginPopupProvider({
51
+ children,
52
+ config,
53
+ isLoggedIn = false,
54
+ }: {
55
+ children: ReactNode
56
+ config?: LoginPopupConfig | null
57
+ isLoggedIn?: boolean
58
+ }) {
59
+ const pathname = usePathname()
60
+ const pathnameRef = useRef(pathname)
61
+ const popupConfig = useMemo(() => normalizeLoginPopupConfig(config), [config])
62
+ const [isPopupOpen, setIsPopupOpen] = useState(false)
63
+ const [hydrated, setHydrated] = useState(false)
64
+
65
+ pathnameRef.current = pathname
66
+
67
+ const dismissPopup = useCallback(() => {
68
+ try {
69
+ localStorage.setItem(popupConfig.storageKey, "1")
70
+ } catch {
71
+ // private mode
72
+ }
73
+ setIsPopupOpen(false)
74
+ }, [popupConfig.storageKey])
75
+
76
+ const openPopup = useCallback(() => {
77
+ setIsPopupOpen(true)
78
+ }, [])
79
+
80
+ useEffect(() => {
81
+ setHydrated(true)
82
+ document.body.classList.remove("login-popup-open")
83
+ document.documentElement.classList.remove("login-popup-open")
84
+ }, [])
85
+
86
+ useEffect(() => {
87
+ if (!hydrated || !popupConfig.enabled || isLoggedIn) return
88
+ if (hasSeenPopup(popupConfig.storageKey)) return
89
+
90
+ let cancelled = false
91
+ let retryTimer: number | undefined
92
+ let opened = false
93
+
94
+ const open = () => {
95
+ if (cancelled || opened) return
96
+ opened = true
97
+ setIsPopupOpen(true)
98
+ }
99
+
100
+ const tryOpen = () => {
101
+ if (cancelled || opened) return
102
+ if (isBlockedPath(pathnameRef.current)) {
103
+ retryTimer = window.setTimeout(tryOpen, 1000)
104
+ return
105
+ }
106
+ if (isCookieBannerPending()) {
107
+ retryTimer = window.setTimeout(tryOpen, 400)
108
+ return
109
+ }
110
+ open()
111
+ }
112
+
113
+ const delayMs = Math.max(popupConfig.showDelayMs || 8000, 3000)
114
+ const timer = window.setTimeout(tryOpen, delayMs)
115
+
116
+ return () => {
117
+ cancelled = true
118
+ window.clearTimeout(timer)
119
+ if (retryTimer) window.clearTimeout(retryTimer)
120
+ }
121
+ }, [
122
+ hydrated,
123
+ isLoggedIn,
124
+ popupConfig.enabled,
125
+ popupConfig.showDelayMs,
126
+ popupConfig.storageKey,
127
+ ])
128
+
129
+ useEffect(() => {
130
+ if (isLoggedIn) setIsPopupOpen(false)
131
+ }, [isLoggedIn])
132
+
133
+ const value = useMemo(
134
+ () => ({ isPopupOpen, dismissPopup, openPopup, popupConfig }),
135
+ [isPopupOpen, dismissPopup, openPopup, popupConfig]
136
+ )
137
+
138
+ return (
139
+ <LoginPopupContext.Provider value={value}>{children}</LoginPopupContext.Provider>
140
+ )
141
+ }
142
+
143
+ export function useLoginPopup() {
144
+ const context = useContext(LoginPopupContext)
145
+ if (!context) {
146
+ throw new Error("useLoginPopup must be used within LoginPopupProvider")
147
+ }
148
+ return context
149
+ }
@@ -0,0 +1,465 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import Image from "next/image"
5
+ import { createPortal } from "react-dom"
6
+ import { useRouter } from "next/navigation"
7
+ import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
8
+ import GoogleAuthSection from "@pradip1995/segment-login-template/google-auth-section"
9
+ import OtpInput from "@pradip1995/segment-login-template/otp-input"
10
+ import {
11
+ sendAuthOtp,
12
+ verifyAuthOtpAndLogin,
13
+ } from "@pradip1995/segment-login-template/auth-server"
14
+ import { SplitPhoneInput } from "@pradip1995/commerce-core/components/split-phone-input"
15
+ import {
16
+ formatPhoneDisplay,
17
+ isValidPhoneParts,
18
+ parsePhoneParts,
19
+ toE164Phone,
20
+ } from "@pradip1995/commerce-core/util/phone"
21
+ import { useLoginPopup } from "./login-popup-provider"
22
+ import "./segment.css"
23
+
24
+ type AuthMethod = "email_auth" | "phone_auth"
25
+ type PopupStep = "identifier" | "verify"
26
+
27
+ export default function LoginPopup({ countryCode = "in" }: { countryCode?: string }) {
28
+ const { isPopupOpen, dismissPopup, popupConfig: config } = useLoginPopup()
29
+ const router = useRouter()
30
+ const [mounted, setMounted] = useState(false)
31
+ const [authMethod, setAuthMethod] = useState<AuthMethod>("phone_auth")
32
+ const [identifier, setIdentifier] = useState("")
33
+ const [otpToken, setOtpToken] = useState<string | null>(null)
34
+ const [otp, setOtp] = useState("")
35
+ const [uiStep, setUiStep] = useState<PopupStep>("identifier")
36
+ const [isNewUser, setIsNewUser] = useState(false)
37
+ const [firstName, setFirstName] = useState("")
38
+ const [error, setError] = useState<string | null>(null)
39
+ const [sending, setSending] = useState(false)
40
+ const [verifying, setVerifying] = useState(false)
41
+ const [resendIn, setResendIn] = useState(0)
42
+ const handleCloseRef = useRef(() => {})
43
+
44
+ useEffect(() => {
45
+ setMounted(true)
46
+ }, [])
47
+
48
+ useEffect(() => {
49
+ if (resendIn <= 0) return
50
+ const timer = window.setTimeout(() => setResendIn((s) => s - 1), 1000)
51
+ return () => window.clearTimeout(timer)
52
+ }, [resendIn])
53
+
54
+ const resetFlow = () => {
55
+ setAuthMethod("phone_auth")
56
+ setIdentifier("")
57
+ setOtpToken(null)
58
+ setOtp("")
59
+ setUiStep("identifier")
60
+ setIsNewUser(false)
61
+ setFirstName("")
62
+ setError(null)
63
+ setSending(false)
64
+ setVerifying(false)
65
+ setResendIn(0)
66
+ }
67
+
68
+ const handleClose = () => {
69
+ dismissPopup()
70
+ resetFlow()
71
+ }
72
+
73
+ handleCloseRef.current = handleClose
74
+
75
+ useEffect(() => {
76
+ if (!isPopupOpen || !mounted) return
77
+
78
+ document.documentElement.classList.add("login-popup-open")
79
+ document.body.classList.add("login-popup-open")
80
+
81
+ const onKeyDown = (event: KeyboardEvent) => {
82
+ if (event.key === "Escape") {
83
+ event.preventDefault()
84
+ handleCloseRef.current()
85
+ }
86
+ }
87
+ window.addEventListener("keydown", onKeyDown)
88
+
89
+ return () => {
90
+ document.documentElement.classList.remove("login-popup-open")
91
+ document.body.classList.remove("login-popup-open")
92
+ window.removeEventListener("keydown", onKeyDown)
93
+ }
94
+ }, [isPopupOpen, mounted])
95
+
96
+ const phoneParts = parsePhoneParts(identifier)
97
+ const phoneReady =
98
+ authMethod === "phone_auth" &&
99
+ isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
100
+ const identifierReady =
101
+ authMethod === "email_auth" ? Boolean(identifier.trim()) : phoneReady
102
+
103
+ async function handleSendOtp(options?: { resend?: boolean }) {
104
+ if (!identifierReady || sending) return
105
+ setSending(true)
106
+ setError(null)
107
+
108
+ if (!options?.resend) {
109
+ setOtp("")
110
+ setFirstName("")
111
+ setOtpToken(null)
112
+ setUiStep("verify")
113
+ }
114
+
115
+ try {
116
+ const res = await sendAuthOtp(
117
+ authMethod === "email_auth"
118
+ ? { email: identifier.trim(), type: "email_auth" }
119
+ : { phone: toE164Phone(identifier), type: "phone_auth" }
120
+ )
121
+ if (!res.success) throw new Error(res.error)
122
+ setOtpToken(res.token ?? null)
123
+ setIsNewUser(res.isNewUser ?? false)
124
+ setOtp("")
125
+ setResendIn(30)
126
+ } catch (err) {
127
+ setError(err instanceof Error ? err.message : "Failed to send code")
128
+ if (!options?.resend) {
129
+ setUiStep("identifier")
130
+ }
131
+ } finally {
132
+ setSending(false)
133
+ }
134
+ }
135
+
136
+ async function handleVerify() {
137
+ if (!otp || otp.length < 6 || !otpToken || verifying || sending) return
138
+ setVerifying(true)
139
+ setError(null)
140
+ try {
141
+ if (isNewUser && !firstName.trim()) {
142
+ throw new Error("Please enter your first name to finish sign in")
143
+ }
144
+ const result = await verifyAuthOtpAndLogin({
145
+ token: otpToken,
146
+ code: otp,
147
+ countryCode,
148
+ first_name: isNewUser ? firstName.trim() : undefined,
149
+ last_name: isNewUser ? "." : undefined,
150
+ skipRedirect: true,
151
+ })
152
+ if (!result.success) {
153
+ throw new Error(result.error || "Verification failed")
154
+ }
155
+ try {
156
+ localStorage.setItem(config.storageKey, "1")
157
+ } catch {
158
+ // ignore
159
+ }
160
+ dismissPopup()
161
+ resetFlow()
162
+ router.refresh()
163
+ } catch (err) {
164
+ setError(err instanceof Error ? err.message : "Verification failed")
165
+ setVerifying(false)
166
+ }
167
+ }
168
+
169
+ if (!config.enabled || !isPopupOpen || !mounted) return null
170
+
171
+ const features = config.featureCards || []
172
+ const identifierDisplay =
173
+ authMethod === "phone_auth" ? formatPhoneDisplay(identifier) || identifier : identifier
174
+
175
+ return createPortal(
176
+ <div
177
+ className="login-popup"
178
+ role="dialog"
179
+ aria-modal="true"
180
+ aria-labelledby="login-popup-title"
181
+ >
182
+ <button
183
+ type="button"
184
+ className="login-popup__backdrop"
185
+ onClick={handleClose}
186
+ aria-label="Close login popup"
187
+ />
188
+
189
+ <div className="login-popup__dialog">
190
+ <button
191
+ type="button"
192
+ className="login-popup__close"
193
+ onClick={handleClose}
194
+ aria-label="Close"
195
+ >
196
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
197
+ <line x1="18" y1="6" x2="6" y2="18" />
198
+ <line x1="6" y1="6" x2="18" y2="18" />
199
+ </svg>
200
+ </button>
201
+
202
+ <div className="login-popup__panel">
203
+ <aside className="login-popup__brand-col" aria-hidden={uiStep !== "identifier"}>
204
+ {config.imageSrc ? (
205
+ <div className="login-popup__panel-image-wrap">
206
+ <Image
207
+ src={config.imageSrc}
208
+ alt=""
209
+ width={800}
210
+ height={500}
211
+ className="login-popup__panel-image"
212
+ loading="lazy"
213
+ unoptimized
214
+ />
215
+ </div>
216
+ ) : null}
217
+
218
+ <div className="login-popup__brand-content">
219
+ <h2 className="login-popup__brand-title">{config.brandTitle}</h2>
220
+ {features.length > 0 && (
221
+ <div className="login-popup__features">
222
+ {features.map((card, index) => (
223
+ <div key={`${card.title}-${index}`} className="login-popup__feature-card">
224
+ {card.imageSrc ? (
225
+ <Image
226
+ src={card.imageSrc}
227
+ alt=""
228
+ width={32}
229
+ height={32}
230
+ className="login-popup__feature-image"
231
+ loading="lazy"
232
+ unoptimized
233
+ />
234
+ ) : (
235
+ <span className="login-popup__feature-icon" aria-hidden>
236
+
237
+ </span>
238
+ )}
239
+ <p className="login-popup__feature-title">{card.title}</p>
240
+ <p className="login-popup__feature-text">{card.text}</p>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ )}
245
+ </div>
246
+ </aside>
247
+
248
+ <div className="login-popup__form-col">
249
+ {uiStep === "identifier" ? (
250
+ <>
251
+ <div className="login-popup__form-body">
252
+ {config.logoSrc ? (
253
+ <div className="login-popup__logo-wrap">
254
+ <Image
255
+ src={config.logoSrc}
256
+ alt={config.shopName || ""}
257
+ width={200}
258
+ height={64}
259
+ className="login-popup__logo"
260
+ loading="lazy"
261
+ unoptimized
262
+ />
263
+ </div>
264
+ ) : null}
265
+
266
+ <h2 id="login-popup-title" className="login-popup__form-title">
267
+ {config.headline}
268
+ </h2>
269
+ {config.subtext ? (
270
+ <p className="login-popup__form-subtext">{config.subtext}</p>
271
+ ) : null}
272
+
273
+ <div className="login-popup__google">
274
+ <GoogleAuthSection countryCode={countryCode} />
275
+ </div>
276
+
277
+ <div className="login-popup__divider">
278
+ <span>Or continue with email / mobile</span>
279
+ </div>
280
+
281
+ <div className="login-popup__form">
282
+ <div className="login-popup__method-tabs">
283
+ <button
284
+ type="button"
285
+ onClick={() => {
286
+ setAuthMethod("phone_auth")
287
+ setIdentifier("")
288
+ setError(null)
289
+ }}
290
+ className={`login-popup__method-tab${
291
+ authMethod === "phone_auth" ? " is-active" : ""
292
+ }`}
293
+ >
294
+ Mobile
295
+ </button>
296
+ <button
297
+ type="button"
298
+ onClick={() => {
299
+ setAuthMethod("email_auth")
300
+ setIdentifier("")
301
+ setError(null)
302
+ }}
303
+ className={`login-popup__method-tab${
304
+ authMethod === "email_auth" ? " is-active" : ""
305
+ }`}
306
+ >
307
+ Email
308
+ </button>
309
+ </div>
310
+
311
+ {authMethod === "phone_auth" ? (
312
+ <div className="login-popup__phone">
313
+ <SplitPhoneInput
314
+ value={identifier}
315
+ onChange={setIdentifier}
316
+ label="Mobile number"
317
+ numberInputId="login-popup-phone"
318
+ data-testid="login-popup-phone"
319
+ />
320
+ </div>
321
+ ) : (
322
+ <input
323
+ type="email"
324
+ value={identifier}
325
+ onChange={(e) => setIdentifier(e.target.value)}
326
+ placeholder="Enter your email"
327
+ className="login-popup__input"
328
+ autoComplete="email"
329
+ />
330
+ )}
331
+
332
+ {error && (
333
+ <p className="login-popup__message login-popup__message--error">
334
+ {error}
335
+ </p>
336
+ )}
337
+
338
+ <button
339
+ type="button"
340
+ disabled={sending || !identifierReady}
341
+ onClick={() => void handleSendOtp()}
342
+ className="login-popup__btn login-popup__btn--primary"
343
+ >
344
+ {sending ? "Continue…" : "Continue"}
345
+ </button>
346
+ </div>
347
+ </div>
348
+
349
+ <footer className="login-popup__legal">
350
+ By continuing, you agree to {config.shopName}&apos;s{" "}
351
+ <LocalizedLink
352
+ href={config.privacyHref || "/privacy-policy"}
353
+ className="login-popup__link"
354
+ onClick={handleClose}
355
+ >
356
+ Privacy Policy
357
+ </LocalizedLink>{" "}
358
+ and{" "}
359
+ <LocalizedLink
360
+ href={config.termsHref || "/terms-of-use"}
361
+ className="login-popup__link"
362
+ onClick={handleClose}
363
+ >
364
+ Terms of Use
365
+ </LocalizedLink>
366
+ .
367
+ </footer>
368
+ </>
369
+ ) : (
370
+ <div className="login-popup__form login-popup__form--verify">
371
+ <h2 id="login-popup-title" className="login-popup__form-title">
372
+ Verify OTP
373
+ </h2>
374
+ <p className="login-popup__otp-hint" aria-live="polite">
375
+ {sending ? (
376
+ <>Sending code to {identifierDisplay}…</>
377
+ ) : (
378
+ <>
379
+ Enter the 6-digit code sent to{" "}
380
+ <span className="login-popup__otp-target">{identifierDisplay}</span>
381
+ </>
382
+ )}
383
+ </p>
384
+
385
+ {isNewUser && (
386
+ <input
387
+ type="text"
388
+ value={firstName}
389
+ onChange={(e) => setFirstName(e.target.value)}
390
+ placeholder="First name"
391
+ required
392
+ className="login-popup__input"
393
+ />
394
+ )}
395
+
396
+ <div className="login-popup__otp">
397
+ <OtpInput
398
+ value={otp}
399
+ onChange={setOtp}
400
+ autoFocus={!isNewUser && !sending && Boolean(otpToken)}
401
+ />
402
+ </div>
403
+
404
+ {error && (
405
+ <p className="login-popup__message login-popup__message--error">
406
+ {error}
407
+ </p>
408
+ )}
409
+
410
+ <button
411
+ type="button"
412
+ disabled={
413
+ sending ||
414
+ verifying ||
415
+ !otpToken ||
416
+ otp.length < 6 ||
417
+ (isNewUser && !firstName.trim())
418
+ }
419
+ onClick={() => void handleVerify()}
420
+ className="login-popup__btn login-popup__btn--primary"
421
+ >
422
+ {sending
423
+ ? "Waiting for code…"
424
+ : verifying
425
+ ? "Verifying…"
426
+ : "Verify & Login"}
427
+ </button>
428
+
429
+ <div className="login-popup__verify-actions">
430
+ <button
431
+ type="button"
432
+ className="login-popup__method-toggle"
433
+ disabled={verifying}
434
+ onClick={() => {
435
+ setUiStep("identifier")
436
+ setOtp("")
437
+ setOtpToken(null)
438
+ setError(null)
439
+ setResendIn(0)
440
+ }}
441
+ >
442
+ Change {authMethod === "email_auth" ? "email" : "number"}
443
+ </button>
444
+ <button
445
+ type="button"
446
+ className="login-popup__method-toggle"
447
+ onClick={() => void handleSendOtp({ resend: true })}
448
+ disabled={sending || verifying || resendIn > 0}
449
+ >
450
+ {sending
451
+ ? "Resending…"
452
+ : resendIn > 0
453
+ ? `Resend in ${resendIn}s`
454
+ : "Resend OTP"}
455
+ </button>
456
+ </div>
457
+ </div>
458
+ )}
459
+ </div>
460
+ </div>
461
+ </div>
462
+ </div>,
463
+ document.body
464
+ )
465
+ }
@@ -0,0 +1,88 @@
1
+ "use client"
2
+
3
+ import GoogleAuthSection from "@pradip1995/segment-login-template/google-auth-section"
4
+ import {
5
+ clearMedusaAuthCookies,
6
+ GOOGLE_LOGIN_COUNTRY_CODE_KEY,
7
+ } from "@pradip1995/commerce-auth/util/google-auth-client"
8
+
9
+ type LoginRequiredModalProps = {
10
+ isOpen: boolean
11
+ onClose: () => void
12
+ countryCode?: string
13
+ message?: string
14
+ }
15
+
16
+ /**
17
+ * Lightweight gated-action modal (wishlist / reviews).
18
+ * Redirects to /account after optional Google, preserving return URL.
19
+ */
20
+ export default function LoginRequiredModal({
21
+ isOpen,
22
+ onClose,
23
+ countryCode = "in",
24
+ message = "Please login to continue.",
25
+ }: LoginRequiredModalProps) {
26
+ if (!isOpen) return null
27
+
28
+ function goToAccount() {
29
+ try {
30
+ const returnUrl = `${window.location.pathname}${window.location.search}`
31
+ localStorage.setItem(
32
+ "loginRedirectUrl",
33
+ returnUrl.includes("?")
34
+ ? `${returnUrl}&login_context=keep`
35
+ : `${returnUrl}?login_context=keep`
36
+ )
37
+ localStorage.setItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY, countryCode)
38
+ clearMedusaAuthCookies()
39
+ } catch {
40
+ // ignore
41
+ }
42
+ window.location.href = `/${countryCode}/account`
43
+ }
44
+
45
+ return (
46
+ <div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
47
+ <button
48
+ type="button"
49
+ className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
50
+ aria-label="Close login required dialog"
51
+ onClick={onClose}
52
+ />
53
+ <div className="relative w-full max-w-sm bg-surface border border-cart-border shadow-xl p-6 text-center">
54
+ <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-accent/10 text-brand-accent">
55
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
56
+ <rect x="3" y="11" width="18" height="11" rx="2" />
57
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
58
+ </svg>
59
+ </div>
60
+ <h2 className="font-heading text-lg font-bold text-heading uppercase tracking-[0.08em] mb-2">
61
+ Login required
62
+ </h2>
63
+ <p className="text-sm text-muted mb-5">{message}</p>
64
+
65
+ <div className="mb-4">
66
+ <GoogleAuthSection countryCode={countryCode} />
67
+ </div>
68
+
69
+ <div className="flex gap-2">
70
+ <button
71
+ type="button"
72
+ onClick={onClose}
73
+ className="flex-1 py-2.5 text-xs font-bold uppercase tracking-widest border border-cart-border text-muted hover:text-heading"
74
+ >
75
+ Cancel
76
+ </button>
77
+ <button
78
+ type="button"
79
+ onClick={goToAccount}
80
+ className="flex-1 py-2.5 text-xs font-bold uppercase tracking-widest bg-brand-primary text-inverse hover:bg-brand-accent"
81
+ >
82
+ Login
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "login-popup",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "loginPopup",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1,403 @@
1
+ /* Sahsha-style first-visit login popup — theme-token driven */
2
+
3
+ .login-popup {
4
+ --lp-logo-h: 3rem;
5
+ position: fixed;
6
+ inset: 0;
7
+ z-index: 10100;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ padding: 1rem;
12
+ pointer-events: none;
13
+ }
14
+
15
+ html.login-popup-open,
16
+ html.login-popup-open body,
17
+ body.login-popup-open {
18
+ overflow: hidden !important;
19
+ overscroll-behavior: none;
20
+ }
21
+
22
+ .login-popup__backdrop {
23
+ position: absolute;
24
+ inset: 0;
25
+ border: none;
26
+ background: rgba(0, 0, 0, 0.45);
27
+ cursor: pointer;
28
+ pointer-events: auto;
29
+ }
30
+
31
+ .login-popup__dialog {
32
+ position: relative;
33
+ width: min(56rem, 100%);
34
+ pointer-events: auto;
35
+ }
36
+
37
+ .login-popup__close {
38
+ position: absolute;
39
+ top: -2.75rem;
40
+ right: 0;
41
+ z-index: 3;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ width: 2.25rem;
46
+ height: 2.25rem;
47
+ border: none;
48
+ border-radius: 999px;
49
+ background: var(--color-surface, #fff);
50
+ color: var(--color-muted, #666);
51
+ cursor: pointer;
52
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
53
+ }
54
+
55
+ .login-popup__panel {
56
+ display: flex;
57
+ flex-direction: column;
58
+ width: 100%;
59
+ max-height: min(36rem, calc(100vh - 4rem));
60
+ background: var(--color-surface, #fff);
61
+ border-radius: 1rem;
62
+ overflow: hidden;
63
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.2);
64
+ }
65
+
66
+ .login-popup__brand-col {
67
+ display: none;
68
+ flex-direction: column;
69
+ background: var(--color-page-bg, #faf9f7);
70
+ border-right: 1px solid var(--color-cart-border, #ece7e1);
71
+ overflow: hidden;
72
+ }
73
+
74
+ .login-popup__panel-image-wrap {
75
+ width: 100%;
76
+ aspect-ratio: 16 / 10;
77
+ overflow: hidden;
78
+ }
79
+
80
+ .login-popup__panel-image {
81
+ width: 100%;
82
+ height: 100%;
83
+ object-fit: cover;
84
+ }
85
+
86
+ .login-popup__brand-content {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 1.25rem;
90
+ flex: 1;
91
+ padding: 1.25rem 1.75rem 2rem;
92
+ }
93
+
94
+ .login-popup__brand-title {
95
+ font-family: var(--font-heading, inherit);
96
+ font-size: 1.35rem;
97
+ font-weight: 700;
98
+ color: var(--color-heading, #1a1a1a);
99
+ letter-spacing: 0.04em;
100
+ text-transform: uppercase;
101
+ }
102
+
103
+ .login-popup__features {
104
+ display: grid;
105
+ grid-template-columns: repeat(3, minmax(0, 1fr));
106
+ gap: 0.75rem;
107
+ }
108
+
109
+ .login-popup__feature-card {
110
+ background: var(--color-surface, #fff);
111
+ border: 1px solid var(--color-cart-border, #ece7e1);
112
+ border-radius: 0.75rem;
113
+ padding: 0.85rem 0.75rem;
114
+ text-align: center;
115
+ }
116
+
117
+ .login-popup__feature-icon {
118
+ display: inline-block;
119
+ color: var(--color-brand-accent, #8b5a6b);
120
+ margin-bottom: 0.35rem;
121
+ }
122
+
123
+ .login-popup__feature-image {
124
+ margin: 0 auto 0.35rem;
125
+ }
126
+
127
+ .login-popup__feature-title {
128
+ font-size: 0.75rem;
129
+ font-weight: 700;
130
+ color: var(--color-heading, #1a1a1a);
131
+ }
132
+
133
+ .login-popup__feature-text {
134
+ margin-top: 0.25rem;
135
+ font-size: 0.65rem;
136
+ color: var(--color-muted, #737373);
137
+ line-height: 1.35;
138
+ }
139
+
140
+ .login-popup__form-col {
141
+ display: flex;
142
+ flex-direction: column;
143
+ min-height: 0;
144
+ overflow-y: auto;
145
+ padding: 1.5rem 1.25rem 1.25rem;
146
+ }
147
+
148
+ .login-popup__form-body {
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 1rem;
152
+ flex: 1;
153
+ }
154
+
155
+ .login-popup__logo-wrap {
156
+ display: flex;
157
+ justify-content: center;
158
+ }
159
+
160
+ .login-popup__logo {
161
+ height: var(--lp-logo-h);
162
+ width: auto;
163
+ object-fit: contain;
164
+ }
165
+
166
+ .login-popup__form-title {
167
+ font-family: var(--font-heading, inherit);
168
+ font-size: 1.35rem;
169
+ font-weight: 700;
170
+ text-align: center;
171
+ color: var(--color-heading, #1a1a1a);
172
+ text-transform: uppercase;
173
+ letter-spacing: 0.06em;
174
+ }
175
+
176
+ .login-popup__form-subtext {
177
+ text-align: center;
178
+ font-size: 0.875rem;
179
+ color: var(--color-muted, #737373);
180
+ }
181
+
182
+ .login-popup__google {
183
+ width: 100%;
184
+ }
185
+
186
+ .login-popup__divider {
187
+ position: relative;
188
+ text-align: center;
189
+ font-size: 0.65rem;
190
+ font-weight: 700;
191
+ letter-spacing: 0.16em;
192
+ text-transform: uppercase;
193
+ color: var(--color-muted, #999);
194
+ }
195
+
196
+ .login-popup__divider::before {
197
+ content: "";
198
+ position: absolute;
199
+ left: 0;
200
+ right: 0;
201
+ top: 50%;
202
+ border-top: 1px solid var(--color-cart-border, #ece7e1);
203
+ }
204
+
205
+ .login-popup__divider span {
206
+ position: relative;
207
+ background: var(--color-surface, #fff);
208
+ padding: 0 0.75rem;
209
+ }
210
+
211
+ .login-popup__form {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 0.85rem;
215
+ }
216
+
217
+ .login-popup__form--verify {
218
+ gap: 1rem;
219
+ padding-top: 0.5rem;
220
+ }
221
+
222
+ .login-popup__method-tabs {
223
+ display: flex;
224
+ gap: 0.35rem;
225
+ padding: 0.25rem;
226
+ background: var(--color-surface-muted, #f3f1ee);
227
+ border-radius: 0.5rem;
228
+ }
229
+
230
+ .login-popup__method-tab {
231
+ flex: 1;
232
+ padding: 0.55rem 0.5rem;
233
+ font-size: 0.7rem;
234
+ font-weight: 700;
235
+ letter-spacing: 0.12em;
236
+ text-transform: uppercase;
237
+ color: var(--color-muted, #737373);
238
+ border-radius: 0.35rem;
239
+ }
240
+
241
+ .login-popup__method-tab.is-active {
242
+ background: var(--color-surface, #fff);
243
+ color: var(--color-heading, #1a1a1a);
244
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
245
+ }
246
+
247
+ .login-popup__input {
248
+ width: 100%;
249
+ border: 1px solid var(--color-cart-border, #e5e0d8);
250
+ background: var(--color-surface, #fff);
251
+ color: var(--color-heading, #1a1a1a);
252
+ border-radius: 0.5rem;
253
+ padding: 0.75rem 0.9rem;
254
+ font-size: 0.9rem;
255
+ }
256
+
257
+ .login-popup__input:focus {
258
+ outline: none;
259
+ border-color: var(--color-brand-primary, #5a2a43);
260
+ }
261
+
262
+ /* Country code + phone — reuse SplitPhoneInput, restyle to popup tokens */
263
+ .login-popup__phone {
264
+ width: 100%;
265
+ }
266
+
267
+ .login-popup__phone .split-phone-input__code-btn,
268
+ .login-popup__phone .split-phone-input__number {
269
+ height: 2.85rem;
270
+ border: 1px solid var(--color-cart-border, #e5e0d8);
271
+ border-radius: 0.5rem;
272
+ background: var(--color-surface, #fff);
273
+ }
274
+
275
+ .login-popup__phone .split-phone-input__code-btn:hover,
276
+ .login-popup__phone .split-phone-input__number:hover {
277
+ background: var(--color-surface-muted, #faf8f5);
278
+ }
279
+
280
+ .login-popup__phone .split-phone-input__number:focus,
281
+ .login-popup__phone .split-phone-input__code-btn:focus-visible {
282
+ outline: none;
283
+ border-color: var(--color-brand-primary, #5a2a43);
284
+ box-shadow: none;
285
+ }
286
+
287
+ .login-popup__phone .split-phone-input__dial {
288
+ color: var(--color-heading, #1a1a1a);
289
+ }
290
+
291
+ .login-popup__phone .split-phone-input__number-label {
292
+ color: var(--color-muted, #737373);
293
+ }
294
+
295
+ .login-popup__phone .split-phone-input__number:focus ~ .split-phone-input__number-label,
296
+ .login-popup__phone
297
+ .split-phone-input__number:not(:placeholder-shown)
298
+ ~ .split-phone-input__number-label {
299
+ color: var(--color-brand-primary, #5a2a43);
300
+ }
301
+
302
+ .login-popup__phone .split-phone-input__menu {
303
+ border-color: var(--color-cart-border, #e5e0d8);
304
+ border-radius: 0.5rem;
305
+ z-index: 60;
306
+ }
307
+
308
+ .login-popup__phone .split-phone-input__option-btn:hover,
309
+ .login-popup__phone .split-phone-input__option-btn--selected {
310
+ background: color-mix(in srgb, var(--color-brand-primary, #5a2a43) 8%, white);
311
+ }
312
+
313
+ .login-popup__btn {
314
+ width: 100%;
315
+ padding: 0.85rem 1rem;
316
+ font-size: 0.7rem;
317
+ font-weight: 700;
318
+ letter-spacing: 0.18em;
319
+ text-transform: uppercase;
320
+ border-radius: 999px;
321
+ transition: background-color 0.2s ease, opacity 0.2s ease;
322
+ }
323
+
324
+ .login-popup__btn--primary {
325
+ background: var(--color-brand-primary, #5a2a43);
326
+ color: var(--color-inverse, #fff);
327
+ }
328
+
329
+ .login-popup__btn--primary:hover:not(:disabled) {
330
+ background: var(--color-brand-accent, #8b5a6b);
331
+ }
332
+
333
+ .login-popup__btn:disabled {
334
+ opacity: 0.5;
335
+ cursor: not-allowed;
336
+ }
337
+
338
+ .login-popup__message--error {
339
+ color: var(--color-brand-sale, #b42318);
340
+ font-size: 0.8rem;
341
+ text-align: center;
342
+ }
343
+
344
+ .login-popup__otp-hint {
345
+ text-align: center;
346
+ font-size: 0.85rem;
347
+ color: var(--color-muted, #737373);
348
+ }
349
+
350
+ .login-popup__otp-target {
351
+ color: var(--color-heading, #1a1a1a);
352
+ font-weight: 600;
353
+ }
354
+
355
+ .login-popup__otp {
356
+ margin: 0.25rem 0;
357
+ }
358
+
359
+ .login-popup__verify-actions {
360
+ display: flex;
361
+ justify-content: space-between;
362
+ gap: 0.75rem;
363
+ }
364
+
365
+ .login-popup__method-toggle {
366
+ font-size: 0.75rem;
367
+ color: var(--color-muted, #737373);
368
+ }
369
+
370
+ .login-popup__method-toggle:hover {
371
+ color: var(--color-brand-accent, #8b5a6b);
372
+ }
373
+
374
+ .login-popup__legal {
375
+ margin-top: 1.25rem;
376
+ font-size: 0.7rem;
377
+ line-height: 1.5;
378
+ text-align: center;
379
+ color: var(--color-muted, #999);
380
+ }
381
+
382
+ .login-popup__link {
383
+ color: var(--color-heading, #1a1a1a);
384
+ text-decoration: underline;
385
+ text-underline-offset: 2px;
386
+ }
387
+
388
+ @media (min-width: 768px) {
389
+ .login-popup__panel {
390
+ flex-direction: row;
391
+ min-height: 28rem;
392
+ }
393
+
394
+ .login-popup__brand-col {
395
+ display: flex;
396
+ width: 52%;
397
+ }
398
+
399
+ .login-popup__form-col {
400
+ width: 48%;
401
+ padding: 1.75rem 1.75rem 1.5rem;
402
+ }
403
+ }
@@ -0,0 +1,45 @@
1
+ "use client"
2
+
3
+ import { LoginPopupProvider } from "./login-popup-provider"
4
+ import LoginPopup from "./login-popup"
5
+ import type { LoginPopupConfig } from "./types"
6
+
7
+ /**
8
+ * Site-wide mount used by layout shells.
9
+ * Renders nothing visible until the auto-show delay (or openPopup()).
10
+ */
11
+ export default function LoginPopupSegment({
12
+ config,
13
+ isLoggedIn = false,
14
+ countryCode = "in",
15
+ ...rest
16
+ }: LoginPopupConfig & {
17
+ config?: LoginPopupConfig | null
18
+ isLoggedIn?: boolean
19
+ countryCode?: string
20
+ enabled?: boolean
21
+ }) {
22
+ // Support both chrome inline data spread (...fields) and nested `config`
23
+ const resolved: LoginPopupConfig = {
24
+ enabled: rest.enabled ?? config?.enabled,
25
+ showDelayMs: rest.showDelayMs ?? config?.showDelayMs,
26
+ storageKey: rest.storageKey ?? config?.storageKey,
27
+ imageSrc: rest.imageSrc ?? config?.imageSrc,
28
+ logoSrc: rest.logoSrc ?? config?.logoSrc,
29
+ brandTitle: rest.brandTitle ?? config?.brandTitle,
30
+ headline: rest.headline ?? config?.headline,
31
+ subtext: rest.subtext ?? config?.subtext,
32
+ featureCards: rest.featureCards ?? config?.featureCards,
33
+ privacyHref: rest.privacyHref ?? config?.privacyHref,
34
+ termsHref: rest.termsHref ?? config?.termsHref,
35
+ shopName: rest.shopName ?? config?.shopName,
36
+ }
37
+
38
+ if (resolved.enabled === false) return null
39
+
40
+ return (
41
+ <LoginPopupProvider config={resolved} isLoggedIn={isLoggedIn}>
42
+ <LoginPopup countryCode={countryCode} />
43
+ </LoginPopupProvider>
44
+ )
45
+ }
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ export type LoginPopupFeatureCard = {
2
+ imageSrc?: string
3
+ title: string
4
+ text: string
5
+ }
6
+
7
+ export type LoginPopupConfig = {
8
+ enabled?: boolean
9
+ showDelayMs?: number
10
+ storageKey?: string
11
+ imageSrc?: string
12
+ logoSrc?: string
13
+ brandTitle?: string
14
+ headline?: string
15
+ subtext?: string
16
+ featureCards?: LoginPopupFeatureCard[]
17
+ privacyHref?: string
18
+ termsHref?: string
19
+ shopName?: string
20
+ }
21
+
22
+ export const DEFAULT_LOGIN_POPUP_FEATURE_CARDS: LoginPopupFeatureCard[] = [
23
+ { title: "Customer-first", text: "Putting you at the center" },
24
+ { title: "Transparent", text: "Honest from the inside out" },
25
+ { title: "Exclusive", text: "Unlock member-only offers" },
26
+ ]
27
+
28
+ export function normalizeLoginPopupConfig(
29
+ input?: LoginPopupConfig | null
30
+ ): Required<
31
+ Pick<
32
+ LoginPopupConfig,
33
+ | "enabled"
34
+ | "showDelayMs"
35
+ | "storageKey"
36
+ | "brandTitle"
37
+ | "headline"
38
+ | "subtext"
39
+ | "privacyHref"
40
+ | "termsHref"
41
+ | "shopName"
42
+ >
43
+ > &
44
+ LoginPopupConfig {
45
+ return {
46
+ enabled: input?.enabled ?? true,
47
+ showDelayMs: input?.showDelayMs ?? 8000,
48
+ storageKey: input?.storageKey ?? "storefront_login_popup_seen",
49
+ imageSrc: input?.imageSrc || "",
50
+ logoSrc: input?.logoSrc || "",
51
+ brandTitle: input?.brandTitle || "Login now",
52
+ headline: input?.headline || "Unlock exclusive deals",
53
+ subtext: input?.subtext || "",
54
+ featureCards: input?.featureCards?.length
55
+ ? input.featureCards
56
+ : DEFAULT_LOGIN_POPUP_FEATURE_CARDS,
57
+ privacyHref: input?.privacyHref || "/privacy-policy",
58
+ termsHref: input?.termsHref || "/terms-of-use",
59
+ shopName: input?.shopName || "our store",
60
+ }
61
+ }