@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.
- package/README.md +21 -1
- package/dist/cli.js +881 -80
- package/dist/cli.js.map +1 -1
- package/dist/index.js +177 -22
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- 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/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 +127 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +53 -0
- package/templates/storefront/src/middleware.ts +6 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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">
|
|
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
|
+
}
|