@carrierllc/mcp 0.2.17 → 0.2.18

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 (28) hide show
  1. package/README.md +21 -1
  2. package/dist/cli.js +881 -80
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +177 -22
  5. package/dist/index.js.map +1 -1
  6. package/package.json +7 -6
  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/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
  16. package/templates/storefront/src/app/activate/[orderId]/page.tsx +16 -12
  17. package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
  18. package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
  19. package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
  20. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +71 -13
  21. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
  22. package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
  24. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +18 -3
  25. package/templates/storefront/src/lib/checkout-order-claim.ts +127 -0
  26. package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
  27. package/templates/storefront/src/lib/verify-checkout-session.ts +53 -0
  28. package/templates/storefront/src/middleware.ts +6 -0
@@ -1,6 +1,19 @@
1
1
  "use client";
2
-
3
- import { useEffect, useState } from "react";
2
+ /**
3
+ * CheckoutClient
4
+ *
5
+ * MANGO-8/9: Purchase-first flow.
6
+ *
7
+ * OLD behaviour: unauthenticated user → redirected to /sign-in (blocking).
8
+ * NEW behaviour:
9
+ * - Unauthenticated → POST /api/checkout/guest → Stripe Checkout directly.
10
+ * - Authenticated → use server-rendered session URL → Stripe Checkout.
11
+ *
12
+ * After Stripe payment success, the user lands on /checkout/success where they
13
+ * are prompted to create an account (email + password, no phone) or sign in.
14
+ * Then they are redirected to /activate/[orderId] for optional WhatsApp/phone setup.
15
+ */
16
+ import { useEffect, useRef, useState } from "react";
4
17
  import { useAuth } from "@clerk/nextjs";
5
18
  import { useRouter } from "next/navigation";
6
19
  import { conversionEvents } from "@/lib/conversion-events";
@@ -8,6 +21,7 @@ import type { SkuTemplate } from "@/vendor/carrier/types";
8
21
 
9
22
  interface Props {
10
23
  plan: SkuTemplate;
24
+ /** Pre-built session URL for authenticated users (server-side). Null for guests. */
11
25
  session: { url: string } | null;
12
26
  }
13
27
 
@@ -15,22 +29,58 @@ export function CheckoutClient({ plan, session }: Props) {
15
29
  const { isSignedIn, isLoaded } = useAuth();
16
30
  const router = useRouter();
17
31
  const [checkoutError, setCheckoutError] = useState<string | null>(null);
32
+ const [loading, setLoading] = useState(false);
33
+ const guestCheckoutPlanIdRef = useRef<string | null>(null);
34
+ const authRefreshAttemptedRef = useRef(false);
18
35
 
19
36
  useEffect(() => {
20
37
  if (!isLoaded) return;
21
- if (!isSignedIn) {
22
- sessionStorage.setItem("checkout_intent", plan.id);
23
- router.push(`/sign-in?redirect_url=/checkout/${plan.id}`);
24
- return;
25
- }
38
+
26
39
  conversionEvents.beginCheckout(plan.name, plan.price_cents / 100);
27
- if (session?.url) {
28
- window.location.href = session.url;
40
+
41
+ if (isSignedIn) {
42
+ // Authenticated path: use the server-rendered session URL.
43
+ if (session?.url) {
44
+ window.location.href = session.url;
45
+ return;
46
+ }
47
+ if (!authRefreshAttemptedRef.current) {
48
+ authRefreshAttemptedRef.current = true;
49
+ router.refresh();
50
+ return;
51
+ }
52
+ setCheckoutError(
53
+ "Checkout is unavailable. Configure NEXT_PUBLIC_CARRIER_API_URL and Clerk keys, then try again.",
54
+ );
29
55
  return;
30
56
  }
31
- setCheckoutError(
32
- "Checkout is unavailable. Configure NEXT_PUBLIC_CARRIER_API_URL and Clerk keys, then try again.",
33
- );
57
+
58
+ // Guest path (MANGO-8): call the guest checkout API no auth required.
59
+ if (guestCheckoutPlanIdRef.current === plan.id) return;
60
+ guestCheckoutPlanIdRef.current = plan.id;
61
+
62
+ setLoading(true);
63
+ fetch("/api/checkout/guest", {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({ templateId: plan.id }),
67
+ })
68
+ .then(async (res) => {
69
+ if (!res.ok) {
70
+ const err = (await res.json()) as { error?: string };
71
+ throw new Error(err.error ?? "Failed to create checkout session");
72
+ }
73
+ return res.json() as Promise<{ url: string }>;
74
+ })
75
+ .then(({ url }) => {
76
+ window.location.href = url;
77
+ })
78
+ .catch((err: unknown) => {
79
+ guestCheckoutPlanIdRef.current = null;
80
+ const msg = err instanceof Error ? err.message : "Checkout unavailable. Please try again.";
81
+ setCheckoutError(msg);
82
+ setLoading(false);
83
+ });
34
84
  }, [isLoaded, isSignedIn, plan, session, router]);
35
85
 
36
86
  if (checkoutError) {
@@ -38,6 +88,12 @@ export function CheckoutClient({ plan, session }: Props) {
38
88
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
39
89
  <p className="text-white/80">{checkoutError}</p>
40
90
  <p className="text-sm text-white/30 mt-2">{plan.name}</p>
91
+ <a
92
+ href="/shop"
93
+ className="mt-6 inline-block rounded-full border border-white/20 px-6 py-2 text-sm text-white hover:bg-white/10 transition-colors"
94
+ >
95
+ ← Back to shop
96
+ </a>
41
97
  </div>
42
98
  );
43
99
  }
@@ -45,7 +101,9 @@ export function CheckoutClient({ plan, session }: Props) {
45
101
  return (
46
102
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
47
103
  <div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
48
- <p className="text-white/60">Preparing your checkout…</p>
104
+ <p className="text-white/60">
105
+ {loading ? "Preparing your checkout…" : "Redirecting to payment…"}
106
+ </p>
49
107
  <p className="text-sm text-white/30 mt-2">{plan.name}</p>
50
108
  </div>
51
109
  );
@@ -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
+ }