@carrierllc/mcp 0.2.17 → 0.2.19

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.
Files changed (30) hide show
  1. package/README.md +21 -1
  2. package/dist/cli.js +893 -80
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +213 -36
  5. package/dist/index.js.map +1 -1
  6. package/package.json +10 -9
  7. package/plugin/.claude-plugin/marketplace.json +2 -2
  8. package/plugin/carrier/.claude-plugin/plugin.json +1 -1
  9. package/plugin/carrier/README.md +31 -4
  10. package/plugin/carrier/agents/carrier-billing-auditor.md +1 -1
  11. package/plugin/carrier/commands/billing.md +8 -1
  12. package/plugin/carrier/commands/provision.md +18 -8
  13. package/plugin/carrier/commands/wallet.md +31 -0
  14. package/plugin/carrier/skills/carrier-operations/SKILL.md +12 -5
  15. package/templates/storefront/package-lock.json +12722 -0
  16. package/templates/storefront/package.json +3 -3
  17. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
  18. package/templates/storefront/src/app/activate/[orderId]/page.tsx +16 -12
  19. package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
  20. package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
  21. package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
  22. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +71 -13
  23. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
  24. package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
  25. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
  26. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +18 -3
  27. package/templates/storefront/src/lib/checkout-order-claim.ts +141 -0
  28. package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
  29. package/templates/storefront/src/lib/verify-checkout-session.ts +70 -0
  30. package/templates/storefront/src/middleware.ts +6 -0
@@ -0,0 +1,415 @@
1
+ "use client";
2
+ /**
3
+ * CheckoutSuccessClient
4
+ *
5
+ * MANGO-8/9: Shown after Stripe payment success.
6
+ *
7
+ * - If already signed in: redirect immediately to /activate/[orderId].
8
+ * - If not signed in: show email + password sign-up form (no phone).
9
+ * After sign-up or sign-in, redirect to /activate/[orderId].
10
+ *
11
+ * The orderId is verified server-side before this component renders.
12
+ */
13
+ import { useState, FormEvent, useEffect, useCallback } from "react";
14
+ import { useSignUp, useSignIn } from "@clerk/nextjs/legacy";
15
+ import { useClerk, useAuth } from "@clerk/nextjs";
16
+ import { useRouter } from "next/navigation";
17
+ import { completeEmailSignUpAfterVerify } from "@/lib/complete-email-sign-up";
18
+
19
+ type AuthMode = "signup" | "signin";
20
+ type FlowStep = "idle" | "credentials" | "verify" | "redirecting" | "claim-error";
21
+
22
+ interface Props {
23
+ orderId: string;
24
+ sessionId: string;
25
+ templateId: string;
26
+ purchaserEmail: string | null;
27
+ }
28
+
29
+ export function CheckoutSuccessClient({
30
+ orderId,
31
+ sessionId,
32
+ templateId,
33
+ purchaserEmail,
34
+ }: Props) {
35
+ const { isLoaded: authLoaded, isSignedIn } = useAuth();
36
+ const { signUp, isLoaded: signUpLoaded } = useSignUp();
37
+ const { signIn, isLoaded: signInLoaded } = useSignIn();
38
+ const { setActive } = useClerk();
39
+ const router = useRouter();
40
+
41
+ const activateUrl = `/activate/${encodeURIComponent(orderId)}`;
42
+ const [mode, setMode] = useState<AuthMode>("signup");
43
+ const [step, setStep] = useState<FlowStep>("idle");
44
+ const [email, setEmail] = useState(purchaserEmail ?? "");
45
+ const [password, setPassword] = useState("");
46
+ const [code, setCode] = useState("");
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [loading, setLoading] = useState(false);
49
+ const emailLocked = Boolean(purchaserEmail);
50
+
51
+ const claimOrderAndRedirect = useCallback(async (afterAuth = false) => {
52
+ setStep("redirecting");
53
+ setError(null);
54
+ try {
55
+ const res = await fetch("/api/checkout/claim", {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ sessionId, templateId }),
59
+ });
60
+ if (!res.ok) {
61
+ const err = (await res.json()) as { error?: string };
62
+ throw new Error(err.error ?? "Failed to link your purchase to this account");
63
+ }
64
+ router.replace(activateUrl);
65
+ } catch (err) {
66
+ setStep(afterAuth || isSignedIn ? "claim-error" : "credentials");
67
+ setError(err instanceof Error ? err.message : "Failed to link your purchase to this account");
68
+ }
69
+ }, [activateUrl, isSignedIn, router, sessionId, templateId]);
70
+
71
+ // If already signed in, claim the paid order before activating.
72
+ useEffect(() => {
73
+ if (!authLoaded) return;
74
+ if (isSignedIn) {
75
+ void claimOrderAndRedirect(true);
76
+ } else {
77
+ setStep("credentials");
78
+ }
79
+ }, [authLoaded, isSignedIn, claimOrderAndRedirect]);
80
+
81
+ function clerkErrMsg(err: unknown): string {
82
+ const e = err as { errors?: Array<{ longMessage?: string; message?: string }> };
83
+ return (
84
+ e?.errors?.[0]?.longMessage ??
85
+ e?.errors?.[0]?.message ??
86
+ "Something went wrong. Please try again."
87
+ );
88
+ }
89
+
90
+ // ── Sign-up flow ──────────────────────────────────────────────────────────
91
+
92
+ async function handleSignUp(e: FormEvent<HTMLFormElement>) {
93
+ e.preventDefault();
94
+ if (!signUpLoaded || !signUp) return;
95
+ if (purchaserEmail && email.toLowerCase() !== purchaserEmail.toLowerCase()) {
96
+ setError("Use the same email address you entered at checkout.");
97
+ return;
98
+ }
99
+ setError(null);
100
+ setLoading(true);
101
+ try {
102
+ await signUp.create({ emailAddress: email, password });
103
+ await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
104
+ setStep("verify");
105
+ } catch (err) {
106
+ setError(clerkErrMsg(err));
107
+ } finally {
108
+ setLoading(false);
109
+ }
110
+ }
111
+
112
+ async function handleVerify(e: FormEvent<HTMLFormElement>) {
113
+ e.preventDefault();
114
+ if (!signUpLoaded || !signUp) return;
115
+ setError(null);
116
+ setLoading(true);
117
+ try {
118
+ const result = await signUp.attemptEmailAddressVerification({ code });
119
+ const completion = await completeEmailSignUpAfterVerify(signUp, result);
120
+ if (completion.status === "complete") {
121
+ await setActive({ session: completion.sessionId });
122
+ await claimOrderAndRedirect(true);
123
+ } else {
124
+ setError(completion.message);
125
+ }
126
+ } catch (err) {
127
+ setError(clerkErrMsg(err));
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ }
132
+
133
+ // ── Sign-in flow ──────────────────────────────────────────────────────────
134
+
135
+ async function handleSignIn(e: FormEvent<HTMLFormElement>) {
136
+ e.preventDefault();
137
+ if (!signInLoaded || !signIn) return;
138
+ if (purchaserEmail && email.toLowerCase() !== purchaserEmail.toLowerCase()) {
139
+ setError("Use the same email address you entered at checkout.");
140
+ return;
141
+ }
142
+ setError(null);
143
+ setLoading(true);
144
+ try {
145
+ const result = await signIn.create({
146
+ identifier: email,
147
+ password,
148
+ });
149
+ if (result.status === "complete" && result.createdSessionId) {
150
+ await setActive({ session: result.createdSessionId });
151
+ await claimOrderAndRedirect(true);
152
+ } else {
153
+ setError("Sign-in incomplete. Please try again.");
154
+ }
155
+ } catch (err) {
156
+ setError(clerkErrMsg(err));
157
+ } finally {
158
+ setLoading(false);
159
+ }
160
+ }
161
+
162
+ // ── Render ────────────────────────────────────────────────────────────────
163
+
164
+ if (step === "idle" || step === "redirecting") {
165
+ return (
166
+ <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
167
+ <div className="text-center space-y-3">
168
+ <div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
169
+ <p className="text-sm text-white/60">
170
+ {step === "redirecting" ? "Activating your eSIM…" : "Loading…"}
171
+ </p>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ if (step === "claim-error") {
178
+ return (
179
+ <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-16">
180
+ <div className="w-full max-w-md space-y-6 text-center">
181
+ <div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-6 py-4">
182
+ <p className="text-lg font-semibold text-emerald-300">✓ Payment successful!</p>
183
+ </div>
184
+ <div className="rounded-2xl border border-white/10 bg-white/5 p-8 backdrop-blur space-y-4">
185
+ <p className="text-sm text-white/80">We could not link this purchase to your account.</p>
186
+ {error && (
187
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
188
+ {error}
189
+ </p>
190
+ )}
191
+ <button
192
+ type="button"
193
+ onClick={() => void claimOrderAndRedirect(true)}
194
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
195
+ >
196
+ Try again
197
+ </button>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-16">
206
+ <div className="w-full max-w-md space-y-6">
207
+ {/* Payment success banner */}
208
+ <div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-6 py-4 text-center">
209
+ <p className="text-lg font-semibold text-emerald-300">✓ Payment successful!</p>
210
+ <p className="mt-1 text-sm text-emerald-200/70">
211
+ Create an account to activate your eSIM.
212
+ </p>
213
+ </div>
214
+
215
+ {/* Auth card */}
216
+ <div className="rounded-2xl border border-white/10 bg-white/5 p-8 backdrop-blur">
217
+ {/* Mode toggle */}
218
+ {step === "credentials" && (
219
+ <div className="flex rounded-lg border border-white/10 p-1 mb-6">
220
+ <button
221
+ type="button"
222
+ onClick={() => { setMode("signup"); setError(null); }}
223
+ className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
224
+ mode === "signup"
225
+ ? "bg-[var(--brand-accent)] text-white"
226
+ : "text-white/40 hover:text-white/70"
227
+ }`}
228
+ >
229
+ Create account
230
+ </button>
231
+ <button
232
+ type="button"
233
+ onClick={() => { setMode("signin"); setError(null); }}
234
+ className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
235
+ mode === "signin"
236
+ ? "bg-[var(--brand-accent)] text-white"
237
+ : "text-white/40 hover:text-white/70"
238
+ }`}
239
+ >
240
+ Sign in
241
+ </button>
242
+ </div>
243
+ )}
244
+
245
+ {/* Sign-up: credentials step */}
246
+ {step === "credentials" && mode === "signup" && (
247
+ <>
248
+ <h2 className="text-base font-semibold text-white mb-1">Create your account</h2>
249
+ <p className="text-xs text-white/40 mb-5">
250
+ {emailLocked
251
+ ? "Use the email from your checkout to create your account."
252
+ : "Email and password — no phone required."}
253
+ </p>
254
+ <form onSubmit={handleSignUp} className="space-y-4">
255
+ <div>
256
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="su-email">
257
+ Email address
258
+ </label>
259
+ <input
260
+ id="su-email"
261
+ type="email"
262
+ autoComplete="email"
263
+ required
264
+ readOnly={emailLocked}
265
+ value={email}
266
+ onChange={(e) => setEmail(e.target.value)}
267
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
268
+ placeholder="you@example.com"
269
+ />
270
+ </div>
271
+ <div>
272
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="su-password">
273
+ Password
274
+ </label>
275
+ <input
276
+ id="su-password"
277
+ type="password"
278
+ autoComplete="new-password"
279
+ required
280
+ minLength={8}
281
+ value={password}
282
+ onChange={(e) => setPassword(e.target.value)}
283
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
284
+ placeholder="Min. 8 characters"
285
+ />
286
+ </div>
287
+ {error && (
288
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
289
+ {error}
290
+ </p>
291
+ )}
292
+ <button
293
+ type="submit"
294
+ disabled={loading || !signUpLoaded}
295
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
296
+ >
297
+ {loading ? "Creating account…" : "Create account & activate eSIM"}
298
+ </button>
299
+ </form>
300
+ </>
301
+ )}
302
+
303
+ {/* Sign-in: credentials step */}
304
+ {step === "credentials" && mode === "signin" && (
305
+ <>
306
+ <h2 className="text-base font-semibold text-white mb-1">Sign in</h2>
307
+ <p className="text-xs text-white/40 mb-5">
308
+ {emailLocked
309
+ ? "Sign in with the email from your checkout to activate your eSIM."
310
+ : "Sign in to activate your eSIM."}
311
+ </p>
312
+ <form onSubmit={handleSignIn} className="space-y-4">
313
+ <div>
314
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="si-email">
315
+ Email address
316
+ </label>
317
+ <input
318
+ id="si-email"
319
+ type="email"
320
+ autoComplete="email"
321
+ required
322
+ readOnly={emailLocked}
323
+ value={email}
324
+ onChange={(e) => setEmail(e.target.value)}
325
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
326
+ placeholder="you@example.com"
327
+ />
328
+ </div>
329
+ <div>
330
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="si-password">
331
+ Password
332
+ </label>
333
+ <input
334
+ id="si-password"
335
+ type="password"
336
+ autoComplete="current-password"
337
+ required
338
+ value={password}
339
+ onChange={(e) => setPassword(e.target.value)}
340
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
341
+ placeholder="Your password"
342
+ />
343
+ </div>
344
+ {error && (
345
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
346
+ {error}
347
+ </p>
348
+ )}
349
+ <button
350
+ type="submit"
351
+ disabled={loading || !signInLoaded}
352
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
353
+ >
354
+ {loading ? "Signing in…" : "Sign in & activate eSIM"}
355
+ </button>
356
+ </form>
357
+ </>
358
+ )}
359
+
360
+ {/* Email verification step */}
361
+ {step === "verify" && (
362
+ <>
363
+ <h2 className="text-base font-semibold text-white mb-1">Check your email</h2>
364
+ <p className="text-xs text-white/40 mb-5">
365
+ We sent a 6-digit code to <span className="text-white/70">{email}</span>.
366
+ </p>
367
+ <form onSubmit={handleVerify} className="space-y-4">
368
+ <div>
369
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="verify-code">
370
+ Verification code
371
+ </label>
372
+ <input
373
+ id="verify-code"
374
+ type="text"
375
+ inputMode="numeric"
376
+ autoComplete="one-time-code"
377
+ required
378
+ maxLength={6}
379
+ value={code}
380
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
381
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-center text-lg tracking-[0.4em] text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
382
+ placeholder="000000"
383
+ />
384
+ </div>
385
+ {error && (
386
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
387
+ {error}
388
+ </p>
389
+ )}
390
+ <button
391
+ type="submit"
392
+ disabled={loading || code.length < 6}
393
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
394
+ >
395
+ {loading ? "Verifying…" : "Verify & activate eSIM"}
396
+ </button>
397
+ </form>
398
+ <button
399
+ type="button"
400
+ onClick={() => { setStep("credentials"); setError(null); setCode(""); }}
401
+ className="mt-4 w-full text-center text-xs text-white/30 hover:text-white/50"
402
+ >
403
+ ← Back
404
+ </button>
405
+ </>
406
+ )}
407
+ </div>
408
+
409
+ <p className="text-center text-xs text-white/30">
410
+ Your payment is confirmed. Account setup is required to receive your eSIM QR code.
411
+ </p>
412
+ </div>
413
+ </div>
414
+ );
415
+ }
@@ -0,0 +1,59 @@
1
+ export const dynamic = "force-dynamic";
2
+ /**
3
+ * /checkout/success
4
+ *
5
+ * MANGO-8/9: Post-payment landing page.
6
+ *
7
+ * The user arrives here after a successful Stripe Checkout payment.
8
+ * Query params from Stripe: ?templateId=<id>&session_id=<stripe_session_id>
9
+ *
10
+ * Flow:
11
+ * 1. Show payment success confirmation.
12
+ * 2. If already signed in → redirect to /activate/[orderId] immediately.
13
+ * 3. If not signed in → show email + password sign-up form (no phone).
14
+ * 4. After sign-up/sign-in → redirect to /activate/[orderId].
15
+ *
16
+ * The orderId is derived from the Stripe session_id (or templateId as fallback)
17
+ * so the activate page can look up the order.
18
+ */
19
+ import { redirect } from "next/navigation";
20
+ import { Suspense } from "react";
21
+ import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
22
+ import { CheckoutSuccessClient } from "./CheckoutSuccessClient";
23
+
24
+ export const metadata = { title: "Payment successful" };
25
+
26
+ interface Props {
27
+ searchParams: Promise<{ session_id?: string; templateId?: string }>;
28
+ }
29
+
30
+ export default async function CheckoutSuccessPage({ searchParams }: Props) {
31
+ const params = await searchParams;
32
+ const sessionId = params.session_id ?? "";
33
+ const templateId = params.templateId ?? "";
34
+
35
+ const verification = await verifyCheckoutSession(sessionId, templateId);
36
+ if (!verification.ok) {
37
+ redirect("/shop");
38
+ }
39
+
40
+ return (
41
+ <Suspense
42
+ fallback={
43
+ <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
44
+ <div className="text-center space-y-3">
45
+ <div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
46
+ <p className="text-sm text-white/60">Loading…</p>
47
+ </div>
48
+ </div>
49
+ }
50
+ >
51
+ <CheckoutSuccessClient
52
+ orderId={verification.orderId}
53
+ sessionId={sessionId}
54
+ templateId={templateId}
55
+ purchaserEmail={verification.purchaserEmail}
56
+ />
57
+ </Suspense>
58
+ );
59
+ }
@@ -0,0 +1,199 @@
1
+ "use client";
2
+ /**
3
+ * StorefrontSignUpClient
4
+ *
5
+ * MANGO-6/7: Custom email + password sign-up for the storefront.
6
+ * Phone number is NEVER required. It is collected optionally on /activate/[orderId].
7
+ *
8
+ * Clerk v6 API (storefront uses @clerk/nextjs ^6):
9
+ * useSignUp() → { signUp, isLoaded }
10
+ * useClerk() → { setActive }
11
+ */
12
+ import { useState, FormEvent } from "react";
13
+ import { useSignUp } from "@clerk/nextjs/legacy";
14
+ import { useClerk } from "@clerk/nextjs";
15
+ import { useSearchParams, useRouter } from "next/navigation";
16
+ import { completeEmailSignUpAfterVerify } from "@/lib/complete-email-sign-up";
17
+ import { sanitizeAuthRedirect } from "@/lib/sanitize-auth-redirect";
18
+
19
+ type FlowStep = "credentials" | "verify" | "complete";
20
+
21
+ export function StorefrontSignUpClient() {
22
+ const searchParams = useSearchParams();
23
+ const rawRedirect = searchParams.get("redirect_url") ?? undefined;
24
+ const redirectTarget = sanitizeAuthRedirect(rawRedirect, "/dashboard");
25
+
26
+ const { signUp, isLoaded } = useSignUp();
27
+ const { setActive } = useClerk();
28
+ const router = useRouter();
29
+
30
+ const [step, setStep] = useState<FlowStep>("credentials");
31
+ const [email, setEmail] = useState("");
32
+ const [password, setPassword] = useState("");
33
+ const [code, setCode] = useState("");
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [loading, setLoading] = useState(false);
36
+
37
+ function clerkErrMsg(err: unknown): string {
38
+ const e = err as { errors?: Array<{ longMessage?: string; message?: string }> };
39
+ return (
40
+ e?.errors?.[0]?.longMessage ??
41
+ e?.errors?.[0]?.message ??
42
+ "Something went wrong. Please try again."
43
+ );
44
+ }
45
+
46
+ async function handleCredentials(e: FormEvent<HTMLFormElement>) {
47
+ e.preventDefault();
48
+ if (!isLoaded || !signUp) return;
49
+ setError(null);
50
+ setLoading(true);
51
+ try {
52
+ await signUp.create({ emailAddress: email, password });
53
+ await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
54
+ setStep("verify");
55
+ } catch (err) {
56
+ setError(clerkErrMsg(err));
57
+ } finally {
58
+ setLoading(false);
59
+ }
60
+ }
61
+
62
+ async function handleVerify(e: FormEvent<HTMLFormElement>) {
63
+ e.preventDefault();
64
+ if (!isLoaded || !signUp) return;
65
+ setError(null);
66
+ setLoading(true);
67
+ try {
68
+ const result = await signUp.attemptEmailAddressVerification({ code });
69
+ const completion = await completeEmailSignUpAfterVerify(signUp, result);
70
+ if (completion.status === "complete") {
71
+ setStep("complete");
72
+ await setActive({ session: completion.sessionId });
73
+ router.replace(redirectTarget);
74
+ } else {
75
+ setError(completion.message);
76
+ }
77
+ } catch (err) {
78
+ setError(clerkErrMsg(err));
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ }
83
+
84
+ return (
85
+ <div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-8 backdrop-blur">
86
+ {step === "credentials" && (
87
+ <>
88
+ <h1 className="text-xl font-bold text-white mb-1">Create account</h1>
89
+ <p className="text-xs text-white/40 mb-6">Email and password — no phone required.</p>
90
+ <form onSubmit={handleCredentials} className="space-y-4">
91
+ <div>
92
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-email">
93
+ Email address
94
+ </label>
95
+ <input
96
+ id="sf-email"
97
+ type="email"
98
+ autoComplete="email"
99
+ required
100
+ value={email}
101
+ onChange={(e) => setEmail(e.target.value)}
102
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
103
+ placeholder="you@example.com"
104
+ />
105
+ </div>
106
+ <div>
107
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-password">
108
+ Password
109
+ </label>
110
+ <input
111
+ id="sf-password"
112
+ type="password"
113
+ autoComplete="new-password"
114
+ required
115
+ minLength={8}
116
+ value={password}
117
+ onChange={(e) => setPassword(e.target.value)}
118
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
119
+ placeholder="Min. 8 characters"
120
+ />
121
+ </div>
122
+ {error && (
123
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
124
+ {error}
125
+ </p>
126
+ )}
127
+ <button
128
+ type="submit"
129
+ disabled={loading || !isLoaded}
130
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
131
+ >
132
+ {loading ? "Creating account…" : "Continue"}
133
+ </button>
134
+ </form>
135
+ <p className="mt-4 text-center text-xs text-white/30">
136
+ Already have an account?{" "}
137
+ <a href="/sign-in" className="text-[var(--brand-accent)] hover:underline">
138
+ Sign in
139
+ </a>
140
+ </p>
141
+ </>
142
+ )}
143
+
144
+ {step === "verify" && (
145
+ <>
146
+ <h1 className="text-xl font-bold text-white mb-1">Check your email</h1>
147
+ <p className="text-xs text-white/40 mb-6">
148
+ We sent a 6-digit code to <span className="text-white/70">{email}</span>.
149
+ </p>
150
+ <form onSubmit={handleVerify} className="space-y-4">
151
+ <div>
152
+ <label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-code">
153
+ Verification code
154
+ </label>
155
+ <input
156
+ id="sf-code"
157
+ type="text"
158
+ inputMode="numeric"
159
+ autoComplete="one-time-code"
160
+ required
161
+ maxLength={6}
162
+ value={code}
163
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
164
+ className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-center text-lg tracking-[0.4em] text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
165
+ placeholder="000000"
166
+ />
167
+ </div>
168
+ {error && (
169
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
170
+ {error}
171
+ </p>
172
+ )}
173
+ <button
174
+ type="submit"
175
+ disabled={loading || code.length < 6}
176
+ className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
177
+ >
178
+ {loading ? "Verifying…" : "Verify email"}
179
+ </button>
180
+ </form>
181
+ <button
182
+ type="button"
183
+ onClick={() => { setStep("credentials"); setError(null); setCode(""); }}
184
+ className="mt-4 w-full text-center text-xs text-white/30 hover:text-white/50"
185
+ >
186
+ ← Back
187
+ </button>
188
+ </>
189
+ )}
190
+
191
+ {step === "complete" && (
192
+ <div className="text-center space-y-3 py-4">
193
+ <div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
194
+ <p className="text-sm text-white/60">Setting up your account…</p>
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }