@carrierllc/mcp 0.11.16 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-Y7D6FHHT.js → chunk-J52VP7E7.js} +14 -2
- package/dist/chunk-J52VP7E7.js.map +1 -0
- package/dist/cli.js +22 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.js +2 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
- package/plugin/carrier/skills/storefront/SKILL.md +2 -2
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +145 -14
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
- package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
- package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
- package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
- package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
- package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
- package/templates/storefront/src/app/dashboard/page.tsx +12 -1
- package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
- package/templates/storefront/src/brand.config.ts +19 -0
- package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
- package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
- package/templates/storefront/src/lib/checkout-order-claim.ts +68 -4
- package/templates/storefront/src/lib/order-route-guard.ts +85 -0
- package/templates/storefront/src/middleware.ts +1 -1
- package/templates/storefront/src/vendor/carrier/client.ts +86 -1
- package/templates/storefront/src/vendor/carrier/types.ts +34 -0
- package/templates/storefront/src/vendor/geo/index.ts +45 -0
- package/templates/storefront/src/vendor/ui/brand.ts +8 -1
- package/dist/chunk-Y7D6FHHT.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carrierllc/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Carrier MCP \u2014 natural-language control of MVNO/eSIM fleets via eSIMVault OCS. Stdio mode for direct integration with Claude Desktop, Cursor, Windsurf, and MCP-compatible clients. Ships the `carrier` CLI (plugin install + white-label eSIM storefront scaffold).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Carrier (Lifecycle Innovations Limited)",
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: carrier-storefront-builder
|
|
3
|
-
description: Scaffolds, white-labels, and deploys the customer-facing eSIM storefront
|
|
3
|
+
description: Scaffolds, white-labels, and deploys the customer-facing eSIM storefront via @carrierllc/cli, and keeps its plans in sync with the Carrier fleet. Use to launch or rebrand a storefront.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
You build and ship white-labeled eSIM storefronts on top of a Carrier fleet.
|
|
7
7
|
|
|
8
|
-
The storefront is a Next.js app (
|
|
8
|
+
The storefront is a Next.js app (bundled template) scaffolded by `@carrierllc/cli`. Its single white-label surface is `src/brand.config.ts` (name, tagline, colors, domain, support, social).
|
|
9
9
|
|
|
10
10
|
Workflow:
|
|
11
11
|
1. **Scaffold** — `npx @carrierllc/cli site create <dir>` with the operator's brand (name, domain, accent hex, support email).
|
|
12
|
-
2. **Brand** — verify `src/brand.config.ts` and `globals.css` reflect the brand; the default accent is Carrier Flame `#FF6B35` on dark `#080C16` — replace with the operator's palette.
|
|
12
|
+
2. **Brand** — verify `src/brand.config.ts` and `globals.css` reflect the brand; the default accent is Carrier Flame `#FF6B35` on dark `#080C16` — replace with the operator's palette. The template ships brand-free — every name a page prints comes from `brand.config.ts`.
|
|
13
13
|
3. **Wire** — set `NEXT_PUBLIC_CARRIER_API_URL` in `.env.local`; ensure the plans shown match `pricing_plans` / `list_package_templates` from the fleet.
|
|
14
14
|
4. **Build & deploy** — `npx @carrierllc/cli site deploy <dir>` → Cloudflare Pages (`wrangler login` first).
|
|
15
15
|
5. **Verify** — load the live URL, confirm it renders, plans are correct, and checkout reaches the control plane.
|
|
@@ -6,7 +6,7 @@ argument-hint: "[create|logo|deploy|targets|clerk|rebrand] [dir]"
|
|
|
6
6
|
|
|
7
7
|
# Storefront
|
|
8
8
|
|
|
9
|
-
Roll out the customer-facing eSIM storefront (
|
|
9
|
+
Roll out the customer-facing eSIM storefront (white-labeled) on top of your Carrier fleet.
|
|
10
10
|
|
|
11
11
|
Works for **any developer** with Carrier MCP (`https://mcp.carrier.llc/mcp`) or the `carrier` CLI. You do not need an image-API key on your laptop.
|
|
12
12
|
|
|
@@ -42,7 +42,7 @@ MCP (any authenticated Carrier user):
|
|
|
42
42
|
generate_storefront_logo
|
|
43
43
|
name: Bananas
|
|
44
44
|
accent: #FF8A00
|
|
45
|
-
tagline:
|
|
45
|
+
tagline: Data that works wherever you land.
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Write the returned `svg` to `public/brand/logo.svg`. Nav and icons read that path.
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ActivateClient
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Shown after sign-up/sign-in on /activate/[orderId].
|
|
6
6
|
*
|
|
7
7
|
* - Fetches the order and shows the real eSIM QR code once provisioning is done.
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* - Offers a one-tap install, then the SM-DP+ address and matching ID, then
|
|
9
|
+
* written steps. A QR code is useless on the one phone the customer is
|
|
10
|
+
* reading this on, so every layer below it has to stand on its own.
|
|
10
11
|
* - Says plainly when it cannot read the order at all. The page used to print
|
|
11
12
|
* a fixed reassurance that a QR code was on its way, and then never
|
|
12
13
|
* ask anyone anything, which reads as progress and is not.
|
|
14
|
+
* - Never spins forever. A spinner is a promise that something is still
|
|
15
|
+
* happening; when the answer is "this order does not exist", "it is not
|
|
16
|
+
* yours", or "provisioning has not finished in the time it normally takes",
|
|
17
|
+
* the page stops and says which, with something the customer can do next.
|
|
13
18
|
* - Optionally collects phone number / WhatsApp for eSIM delivery notifications.
|
|
14
19
|
* Phone is NEVER required — user can skip.
|
|
15
20
|
*/
|
|
@@ -23,12 +28,32 @@ interface Props {
|
|
|
23
28
|
type LoadState =
|
|
24
29
|
| { phase: "loading" }
|
|
25
30
|
| { phase: "ready"; order: EsimOrder }
|
|
26
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `terminal` means retrying cannot change the answer. A 404 or a 403 is a
|
|
33
|
+
* fact about this order, not a hiccup, so the page offers support instead of
|
|
34
|
+
* a "Try again" button that will fail identically.
|
|
35
|
+
*/
|
|
36
|
+
| { phase: "error"; message: string; terminal: boolean }
|
|
37
|
+
/** Still provisioning after the polls ran out. Not an error, not progress. */
|
|
38
|
+
| { phase: "stalled"; order: EsimOrder };
|
|
27
39
|
|
|
28
40
|
/** Provisioning is usually seconds. Poll a bounded number of times, then stop. */
|
|
29
41
|
const POLL_INTERVAL_MS = 4000;
|
|
30
42
|
const MAX_POLLS = 15;
|
|
31
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Apple's universal eSIM install link — iOS 17.4 and later hand the activation
|
|
46
|
+
* string straight to Settings, so the customer never types anything.
|
|
47
|
+
*
|
|
48
|
+
* Nothing equivalent exists on Android: install there is Settings → Network →
|
|
49
|
+
* add eSIM → enter manually, which is what the written steps below are for.
|
|
50
|
+
*/
|
|
51
|
+
function directInstallUrl(activationCode: string): string {
|
|
52
|
+
return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${encodeURIComponent(
|
|
53
|
+
activationCode,
|
|
54
|
+
)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
export function ActivateClient({ orderId }: Props) {
|
|
33
58
|
const [phone, setPhone] = useState("");
|
|
34
59
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -37,6 +62,11 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
37
62
|
const [state, setState] = useState<LoadState>({ phase: "loading" });
|
|
38
63
|
const [copied, setCopied] = useState(false);
|
|
39
64
|
const pollsRef = useRef(0);
|
|
65
|
+
// Bumped by "Check again". The polling effect depends on it, so a new value
|
|
66
|
+
// tears the old loop down and starts a fresh one. Resetting the counter and
|
|
67
|
+
// calling loadOrder once would fetch a single time and then sit on the
|
|
68
|
+
// spinner forever, because loadOrder is stable and the effect never re-runs.
|
|
69
|
+
const [pollCycle, setPollCycle] = useState(0);
|
|
40
70
|
|
|
41
71
|
const loadOrder = useCallback(async (): Promise<EsimOrder | null> => {
|
|
42
72
|
try {
|
|
@@ -46,11 +76,21 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
46
76
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
47
77
|
order?: EsimOrder;
|
|
48
78
|
error?: string;
|
|
79
|
+
code?: string;
|
|
49
80
|
};
|
|
50
81
|
if (!res.ok || !payload.order) {
|
|
82
|
+
// 404 and 403 are settled answers: the order is unknown, or it is not
|
|
83
|
+
// this account's. Retrying returns the same status, and a spinner over
|
|
84
|
+
// the top of it is a lie about what the page is waiting for.
|
|
85
|
+
const terminal = res.status === 404 || res.status === 403 || payload.code === "not_found";
|
|
51
86
|
setState({
|
|
52
87
|
phase: "error",
|
|
53
|
-
|
|
88
|
+
terminal,
|
|
89
|
+
message:
|
|
90
|
+
payload.error ??
|
|
91
|
+
(terminal
|
|
92
|
+
? "We can't find this order on your account."
|
|
93
|
+
: "We could not load your eSIM details right now."),
|
|
54
94
|
});
|
|
55
95
|
return null;
|
|
56
96
|
}
|
|
@@ -59,6 +99,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
59
99
|
} catch {
|
|
60
100
|
setState({
|
|
61
101
|
phase: "error",
|
|
102
|
+
terminal: false,
|
|
62
103
|
message: "We could not reach your eSIM details. Check your connection and try again.",
|
|
63
104
|
});
|
|
64
105
|
return null;
|
|
@@ -73,10 +114,16 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
73
114
|
const order = await loadOrder();
|
|
74
115
|
if (cancelled) return;
|
|
75
116
|
const stillWorking = order?.status === "pending" || order?.status === "provisioning";
|
|
76
|
-
if (stillWorking
|
|
117
|
+
if (!stillWorking) return;
|
|
118
|
+
if (pollsRef.current < MAX_POLLS) {
|
|
77
119
|
pollsRef.current += 1;
|
|
78
120
|
timer = setTimeout(tick, POLL_INTERVAL_MS);
|
|
121
|
+
return;
|
|
79
122
|
}
|
|
123
|
+
// Out of polls and still not ready. Stop the spinner and say so — it has
|
|
124
|
+
// now taken materially longer than provisioning normally does, and
|
|
125
|
+
// "we're preparing your eSIM" under an animation is no longer true.
|
|
126
|
+
setState({ phase: "stalled", order });
|
|
80
127
|
}
|
|
81
128
|
|
|
82
129
|
void tick();
|
|
@@ -84,7 +131,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
84
131
|
cancelled = true;
|
|
85
132
|
if (timer) clearTimeout(timer);
|
|
86
133
|
};
|
|
87
|
-
}, [loadOrder]);
|
|
134
|
+
}, [loadOrder, pollCycle]);
|
|
88
135
|
|
|
89
136
|
async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
|
|
90
137
|
e.preventDefault();
|
|
@@ -120,7 +167,8 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
120
167
|
}
|
|
121
168
|
}
|
|
122
169
|
|
|
123
|
-
const order =
|
|
170
|
+
const order =
|
|
171
|
+
state.phase === "ready" || state.phase === "stalled" ? state.order : null;
|
|
124
172
|
const ready = order?.status === "ready";
|
|
125
173
|
const manualCode =
|
|
126
174
|
order?.activationCode ??
|
|
@@ -150,27 +198,68 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
150
198
|
{state.phase === "error" && (
|
|
151
199
|
<>
|
|
152
200
|
<p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
|
|
153
|
-
We couldn
|
|
201
|
+
{state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}
|
|
154
202
|
</p>
|
|
155
203
|
<p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
|
|
156
204
|
<p className="text-sand-400 text-xs mb-4">
|
|
157
205
|
Your payment is safe and your order number is above. Quote it if you contact us.
|
|
158
206
|
</p>
|
|
207
|
+
<div className="flex items-center justify-center gap-3">
|
|
208
|
+
{/* No "Try again" on a terminal answer: the same request would
|
|
209
|
+
return the same status, and a button that cannot work is
|
|
210
|
+
worse than no button. */}
|
|
211
|
+
{!state.terminal && (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => {
|
|
215
|
+
pollsRef.current = 0;
|
|
216
|
+
setState({ phase: "loading" });
|
|
217
|
+
void loadOrder();
|
|
218
|
+
}}
|
|
219
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
220
|
+
>
|
|
221
|
+
Try again
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
<a
|
|
225
|
+
href="/contact"
|
|
226
|
+
className={
|
|
227
|
+
state.terminal
|
|
228
|
+
? "rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
229
|
+
: "rounded-full border border-sand-300 dark:border-sand-700 px-5 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
|
|
230
|
+
}
|
|
231
|
+
>
|
|
232
|
+
Contact support
|
|
233
|
+
</a>
|
|
234
|
+
</div>
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{state.phase === "stalled" && (
|
|
239
|
+
<>
|
|
240
|
+
<p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
|
|
241
|
+
This is taking longer than it should
|
|
242
|
+
</p>
|
|
243
|
+
<p className="text-sand-600 dark:text-sand-400 text-sm mb-4">
|
|
244
|
+
Your payment went through and your order is queued, but the eSIM isn't ready
|
|
245
|
+
yet. We'll email your QR code as soon as it is — you don't have to wait
|
|
246
|
+
here.
|
|
247
|
+
</p>
|
|
159
248
|
<div className="flex items-center justify-center gap-3">
|
|
160
249
|
<button
|
|
161
250
|
type="button"
|
|
162
251
|
onClick={() => {
|
|
163
252
|
pollsRef.current = 0;
|
|
164
253
|
setState({ phase: "loading" });
|
|
165
|
-
|
|
254
|
+
setPollCycle((n) => n + 1);
|
|
166
255
|
}}
|
|
167
|
-
className="rounded-full
|
|
256
|
+
className="rounded-full border border-sand-300 dark:border-sand-700 px-5 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
|
|
168
257
|
>
|
|
169
|
-
|
|
258
|
+
Check again
|
|
170
259
|
</button>
|
|
171
260
|
<a
|
|
172
261
|
href="/contact"
|
|
173
|
-
className="rounded-full
|
|
262
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
174
263
|
>
|
|
175
264
|
Contact support
|
|
176
265
|
</a>
|
|
@@ -178,7 +267,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
178
267
|
</>
|
|
179
268
|
)}
|
|
180
269
|
|
|
181
|
-
{order && !ready && order.status !== "failed" && (
|
|
270
|
+
{state.phase === "ready" && order && !ready && order.status !== "failed" && (
|
|
182
271
|
<>
|
|
183
272
|
<div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
|
|
184
273
|
<p className="text-sand-600 dark:text-sand-400 text-sm mb-2">
|
|
@@ -234,6 +323,18 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
234
323
|
)}
|
|
235
324
|
</div>
|
|
236
325
|
|
|
326
|
+
{/* One tap, no typing — iOS 17.4+ only, and harmless everywhere else:
|
|
327
|
+
a phone that cannot handle the link opens a page that explains the
|
|
328
|
+
manual route, which is the next card down. */}
|
|
329
|
+
{ready && manualCode && (
|
|
330
|
+
<a
|
|
331
|
+
href={directInstallUrl(manualCode)}
|
|
332
|
+
className="mb-4 block w-full rounded-full bg-[var(--brand-accent)] px-6 py-3 text-center text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
333
|
+
>
|
|
334
|
+
Install this eSIM on this iPhone
|
|
335
|
+
</a>
|
|
336
|
+
)}
|
|
337
|
+
|
|
237
338
|
{/* Manual install — always available once we know the credentials, because
|
|
238
339
|
a QR is useless on the one phone the customer is reading this on. */}
|
|
239
340
|
{ready && (order?.smdpAddress || manualCode) && (
|
|
@@ -276,6 +377,36 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
276
377
|
{copied ? "Copied" : "Copy activation string"}
|
|
277
378
|
</button>
|
|
278
379
|
)}
|
|
380
|
+
|
|
381
|
+
{/* The steps in words. A customer whose camera cannot reach a second
|
|
382
|
+
screen, and whose phone is too old for the one-tap link, has the
|
|
383
|
+
credentials above and no idea where to type them. */}
|
|
384
|
+
<div className="mt-6 grid gap-5 border-t border-sand-200 pt-5 dark:border-sand-800">
|
|
385
|
+
<div>
|
|
386
|
+
<p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
|
|
387
|
+
On iPhone
|
|
388
|
+
</p>
|
|
389
|
+
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
390
|
+
<li>Open Settings, then Cellular (or Mobile Service).</li>
|
|
391
|
+
<li>Tap Add eSIM, then Use QR Code.</li>
|
|
392
|
+
<li>Tap Enter Details Manually.</li>
|
|
393
|
+
<li>Paste the SM-DP+ address and activation code from above.</li>
|
|
394
|
+
<li>Leave data roaming on — that is how the plan connects abroad.</li>
|
|
395
|
+
</ol>
|
|
396
|
+
</div>
|
|
397
|
+
<div>
|
|
398
|
+
<p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
|
|
399
|
+
On Android
|
|
400
|
+
</p>
|
|
401
|
+
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
402
|
+
<li>Open Settings, then Network & internet.</li>
|
|
403
|
+
<li>Tap SIMs, then Add SIM or Download a SIM instead.</li>
|
|
404
|
+
<li>Choose Need help? then Enter it manually.</li>
|
|
405
|
+
<li>Paste the activation code from above.</li>
|
|
406
|
+
<li>Turn on roaming for this SIM once it appears.</li>
|
|
407
|
+
</ol>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
279
410
|
</div>
|
|
280
411
|
)}
|
|
281
412
|
|
|
@@ -2,7 +2,7 @@ export const dynamic = "force-dynamic";
|
|
|
2
2
|
/**
|
|
3
3
|
* /activate/[orderId]
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Post-purchase eSIM activation page.
|
|
6
6
|
*
|
|
7
7
|
* Phone number / WhatsApp is collected HERE — optionally — after the user
|
|
8
8
|
* has already paid and created their account. It is never required at sign-up.
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /api/checkout/guest
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Purchase-first flow — creates a Stripe Checkout session for
|
|
5
5
|
* unauthenticated users. No Clerk auth required.
|
|
6
6
|
*
|
|
7
|
-
* Body: { templateId: string, channel?: string }
|
|
7
|
+
* Body: { templateId: string, channel?: string, uiMode?: "hosted" | "embedded" }
|
|
8
8
|
*
|
|
9
|
-
* Returns
|
|
9
|
+
* Returns one of:
|
|
10
|
+
* { url: string } — hosted: send the browser there
|
|
11
|
+
* { clientSecret: string, sessionId } — embedded: mount it in our page
|
|
10
12
|
*
|
|
11
|
-
*
|
|
13
|
+
* Embedded is asked for only when the storefront has a Stripe publishable key,
|
|
14
|
+
* and the route still answers "hosted" for anything else, so a deploy without
|
|
15
|
+
* one keeps selling. The session is created with redirect_on_completion set to
|
|
16
|
+
* `if_required`: a redirect-based payment method still returns through
|
|
17
|
+
* `return_url`, and a plain card finishes in the iframe and hands control back
|
|
18
|
+
* to the page, which navigates onward itself.
|
|
19
|
+
*
|
|
20
|
+
* The success / return URL encodes the templateId so the /checkout/success
|
|
12
21
|
* page can display the correct plan and prompt sign-up / sign-in.
|
|
13
22
|
*
|
|
14
23
|
* The line item is built inline from the plan's own price (`price_data`, no
|
|
@@ -22,10 +31,22 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
22
31
|
import { fetchTemplateCatalog } from "@/vendor/carrier/client";
|
|
23
32
|
import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
|
|
24
33
|
|
|
34
|
+
interface GuestCheckoutBody {
|
|
35
|
+
templateId?: string;
|
|
36
|
+
channel?: string;
|
|
37
|
+
uiMode?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Set when this purchase adds data to an eSIM that already exists. Carried
|
|
40
|
+
* into Stripe metadata so fulfilment tops up that ICCID instead of
|
|
41
|
+
* provisioning a second eSIM the customer neither wants nor can install.
|
|
42
|
+
*/
|
|
43
|
+
topUpForOrderId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
26
|
-
let body:
|
|
47
|
+
let body: GuestCheckoutBody;
|
|
27
48
|
try {
|
|
28
|
-
body = (await req.json()) as
|
|
49
|
+
body = (await req.json()) as GuestCheckoutBody;
|
|
29
50
|
} catch {
|
|
30
51
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
31
52
|
}
|
|
@@ -71,6 +92,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
71
92
|
const successUrl =
|
|
72
93
|
`${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}`;
|
|
73
94
|
const cancelUrl = `${appUrl}/shop`;
|
|
95
|
+
const embedded = body.uiMode === "embedded";
|
|
96
|
+
// Same charset gate the rest of the storefront applies to a Stripe session id.
|
|
97
|
+
const topUpForOrderId =
|
|
98
|
+
typeof body.topUpForOrderId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(body.topUpForOrderId)
|
|
99
|
+
? body.topUpForOrderId
|
|
100
|
+
: null;
|
|
74
101
|
|
|
75
102
|
const formBody = new URLSearchParams({
|
|
76
103
|
mode: "payment",
|
|
@@ -79,8 +106,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
79
106
|
"line_items[0][price_data][product_data][name]": plan.name,
|
|
80
107
|
"line_items[0][price_data][product_data][description]": plan.description,
|
|
81
108
|
"line_items[0][quantity]": "1",
|
|
82
|
-
success_url
|
|
83
|
-
cancel_url
|
|
109
|
+
// Embedded sessions take `return_url` and reject `success_url` /
|
|
110
|
+
// `cancel_url`: there is no hosted page to come back from, and cancelling
|
|
111
|
+
// is just closing the form.
|
|
112
|
+
...(embedded
|
|
113
|
+
? {
|
|
114
|
+
ui_mode: "embedded",
|
|
115
|
+
redirect_on_completion: "if_required",
|
|
116
|
+
return_url: successUrl,
|
|
117
|
+
}
|
|
118
|
+
: { success_url: successUrl, cancel_url: cancelUrl }),
|
|
84
119
|
// Store templateId in BOTH session and payment_intent metadata so the
|
|
85
120
|
// provisioning webhook can read it. The `checkout.session.completed` event
|
|
86
121
|
// exposes `session.metadata` (NOT `payment_intent.metadata`), so the
|
|
@@ -95,6 +130,15 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
95
130
|
"payment_intent_data[metadata][channel]": channel,
|
|
96
131
|
// Allow guest checkout (no Stripe account required)
|
|
97
132
|
"payment_method_collection": "always",
|
|
133
|
+
// Session-level and payment-intent-level, for the same reason templateId
|
|
134
|
+
// is: `checkout.session.completed` only exposes session metadata, and a
|
|
135
|
+
// top-up that loses its target order provisions a brand-new eSIM.
|
|
136
|
+
...(topUpForOrderId
|
|
137
|
+
? {
|
|
138
|
+
"metadata[topUpForOrderId]": topUpForOrderId,
|
|
139
|
+
"payment_intent_data[metadata][topUpForOrderId]": topUpForOrderId,
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
98
142
|
});
|
|
99
143
|
|
|
100
144
|
const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
|
@@ -117,6 +161,25 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
117
161
|
);
|
|
118
162
|
}
|
|
119
163
|
|
|
120
|
-
const session = (await resp.json()) as {
|
|
164
|
+
const session = (await resp.json()) as {
|
|
165
|
+
id?: string;
|
|
166
|
+
url?: string;
|
|
167
|
+
client_secret?: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (embedded) {
|
|
171
|
+
if (!session.client_secret || !session.id) {
|
|
172
|
+
// Stripe accepted the session but did not return what an embedded form
|
|
173
|
+
// needs. Say so rather than shipping an empty iframe — the client falls
|
|
174
|
+
// back to the hosted page on this code.
|
|
175
|
+
console.error("[checkout] embedded session has no client_secret");
|
|
176
|
+
return NextResponse.json(
|
|
177
|
+
{ error: "We could not start checkout. Please try again in a moment.", code: "embed_unavailable" },
|
|
178
|
+
{ status: 502 },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return NextResponse.json({ clientSecret: session.client_secret, sessionId: session.id });
|
|
182
|
+
}
|
|
183
|
+
|
|
121
184
|
return NextResponse.json({ url: session.url });
|
|
122
185
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/orders/[orderId]/cancel
|
|
3
|
+
*
|
|
4
|
+
* Stop billing one eSIM. Signed-in callers only, and only for an order they
|
|
5
|
+
* have already claimed.
|
|
6
|
+
*
|
|
7
|
+
* POST rather than DELETE because what happens upstream is a state change on a
|
|
8
|
+
* plan, not the removal of a record: the order, the ICCID and the usage
|
|
9
|
+
* history all survive a cancellation and the customer can still read them.
|
|
10
|
+
*/
|
|
11
|
+
import { NextResponse } from "next/server";
|
|
12
|
+
import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
|
|
13
|
+
import { cancelOrder } from "@/vendor/carrier/client";
|
|
14
|
+
|
|
15
|
+
export const dynamic = "force-dynamic";
|
|
16
|
+
|
|
17
|
+
interface Context {
|
|
18
|
+
params: Promise<{ orderId: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function POST(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
22
|
+
const guard = await guardOrderRoute(ctx.params);
|
|
23
|
+
if (!guard.ok) return guard.response;
|
|
24
|
+
|
|
25
|
+
const result = await cancelOrder(guard.orderId);
|
|
26
|
+
if (result.ok) {
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
cancelledAt: result.cancelledAt ?? new Date().toISOString(),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fulfilmentFailureResponse(result.reason, "cancel this plan", guard.orderId);
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/orders/[orderId]/top-up-options
|
|
3
|
+
*
|
|
4
|
+
* What more data on this eSIM would cost. Signed-in callers only, and only for
|
|
5
|
+
* an order they have already claimed.
|
|
6
|
+
*
|
|
7
|
+
* An empty list is a successful answer: this plan cannot be topped up. The
|
|
8
|
+
* dashboard says so rather than showing a button that refuses on tap.
|
|
9
|
+
*/
|
|
10
|
+
import { NextResponse } from "next/server";
|
|
11
|
+
import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
|
|
12
|
+
import { fetchTopUpOptions } from "@/vendor/carrier/client";
|
|
13
|
+
|
|
14
|
+
export const dynamic = "force-dynamic";
|
|
15
|
+
|
|
16
|
+
interface Context {
|
|
17
|
+
params: Promise<{ orderId: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
21
|
+
const guard = await guardOrderRoute(ctx.params);
|
|
22
|
+
if (!guard.ok) return guard.response;
|
|
23
|
+
|
|
24
|
+
const lookup = await fetchTopUpOptions(guard.orderId);
|
|
25
|
+
if (lookup.ok) return NextResponse.json({ options: lookup.options });
|
|
26
|
+
|
|
27
|
+
return fulfilmentFailureResponse(lookup.reason, "load top-up options", guard.orderId);
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/orders/[orderId]/usage
|
|
3
|
+
*
|
|
4
|
+
* The usage meter's data source. Signed-in callers only, and only for an order
|
|
5
|
+
* they have already claimed.
|
|
6
|
+
*/
|
|
7
|
+
import { NextResponse } from "next/server";
|
|
8
|
+
import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
|
|
9
|
+
import { fetchUsage } from "@/vendor/carrier/client";
|
|
10
|
+
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
interface Context {
|
|
14
|
+
params: Promise<{ orderId: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
18
|
+
const guard = await guardOrderRoute(ctx.params);
|
|
19
|
+
if (!guard.ok) return guard.response;
|
|
20
|
+
|
|
21
|
+
const lookup = await fetchUsage(guard.orderId);
|
|
22
|
+
if (lookup.ok) return NextResponse.json({ usage: lookup.usage });
|
|
23
|
+
|
|
24
|
+
return fulfilmentFailureResponse(lookup.reason, "read your usage", guard.orderId);
|
|
25
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /api/profile/phone
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Saves an optional phone number to the user's Clerk profile.
|
|
5
5
|
* Called from /activate/[orderId] — never required, always skippable.
|
|
6
6
|
*
|
|
7
7
|
* Body: { phone: string }
|