@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.
- package/README.md +21 -1
- package/dist/cli.js +893 -80
- package/dist/cli.js.map +1 -1
- package/dist/index.js +213 -36
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/plugin/.claude-plugin/marketplace.json +2 -2
- package/plugin/carrier/.claude-plugin/plugin.json +1 -1
- package/plugin/carrier/README.md +31 -4
- package/plugin/carrier/agents/carrier-billing-auditor.md +1 -1
- package/plugin/carrier/commands/billing.md +8 -1
- package/plugin/carrier/commands/provision.md +18 -8
- package/plugin/carrier/commands/wallet.md +31 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +12 -5
- package/templates/storefront/package-lock.json +12722 -0
- package/templates/storefront/package.json +3 -3
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +16 -12
- package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +71 -13
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +18 -3
- package/templates/storefront/src/lib/checkout-order-claim.ts +141 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +70 -0
- 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
|
+
}
|