@carrierllc/mcp 0.11.17 → 0.13.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-EHGZRVSO.js → chunk-ZUUC3J4J.js} +26 -3
- package/dist/chunk-ZUUC3J4J.js.map +1 -0
- package/dist/cli.js +397 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.js +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 +205 -17
- 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]/email/route.ts +73 -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 +113 -8
- package/templates/storefront/src/lib/esim-install.ts +74 -0
- package/templates/storefront/src/lib/order-route-guard.ts +85 -0
- package/templates/storefront/src/lib/qr-delivery-email.ts +106 -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/email/qr-delivery.ts +254 -0
- package/templates/storefront/src/vendor/email/resend.ts +140 -0
- package/templates/storefront/src/vendor/geo/index.ts +45 -0
- package/templates/storefront/src/vendor/ui/brand.ts +8 -1
- package/dist/chunk-EHGZRVSO.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carrierllc/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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,18 +2,29 @@
|
|
|
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
|
*/
|
|
16
21
|
import { useCallback, useEffect, useRef, useState, FormEvent } from "react";
|
|
22
|
+
import {
|
|
23
|
+
ANDROID_INSTALL_STEPS,
|
|
24
|
+
IPHONE_INSTALL_STEPS,
|
|
25
|
+
directInstallUrl,
|
|
26
|
+
manualActivationCode,
|
|
27
|
+
} from "@/lib/esim-install";
|
|
17
28
|
import type { EsimOrder } from "@/vendor/carrier/types";
|
|
18
29
|
|
|
19
30
|
interface Props {
|
|
@@ -23,12 +34,32 @@ interface Props {
|
|
|
23
34
|
type LoadState =
|
|
24
35
|
| { phase: "loading" }
|
|
25
36
|
| { phase: "ready"; order: EsimOrder }
|
|
26
|
-
|
|
37
|
+
/**
|
|
38
|
+
* `terminal` means retrying cannot change the answer. A 404 or a 403 is a
|
|
39
|
+
* fact about this order, not a hiccup, so the page offers support instead of
|
|
40
|
+
* a "Try again" button that will fail identically.
|
|
41
|
+
*/
|
|
42
|
+
| { phase: "error"; message: string; terminal: boolean }
|
|
43
|
+
/** Still provisioning after the polls ran out. Not an error, not progress. */
|
|
44
|
+
| { phase: "stalled"; order: EsimOrder };
|
|
27
45
|
|
|
28
46
|
/** Provisioning is usually seconds. Poll a bounded number of times, then stop. */
|
|
29
47
|
const POLL_INTERVAL_MS = 4000;
|
|
30
48
|
const MAX_POLLS = 15;
|
|
31
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What happened to the delivery email, as the page is allowed to describe it.
|
|
52
|
+
*
|
|
53
|
+
* "unconfigured" is on this list because a storefront with no Resend key is a
|
|
54
|
+
* working storefront, and the page must say "we cannot email this" rather than
|
|
55
|
+
* letting the customer leave believing a copy is on its way.
|
|
56
|
+
*/
|
|
57
|
+
type EmailState =
|
|
58
|
+
| { phase: "idle" }
|
|
59
|
+
| { phase: "sending" }
|
|
60
|
+
| { phase: "sent" }
|
|
61
|
+
| { phase: "unavailable"; reason: string };
|
|
62
|
+
|
|
32
63
|
export function ActivateClient({ orderId }: Props) {
|
|
33
64
|
const [phone, setPhone] = useState("");
|
|
34
65
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -36,7 +67,14 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
36
67
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
37
68
|
const [state, setState] = useState<LoadState>({ phase: "loading" });
|
|
38
69
|
const [copied, setCopied] = useState(false);
|
|
70
|
+
const [email, setEmail] = useState<EmailState>({ phase: "idle" });
|
|
39
71
|
const pollsRef = useRef(0);
|
|
72
|
+
const emailAskedRef = useRef(false);
|
|
73
|
+
// Bumped by "Check again". The polling effect depends on it, so a new value
|
|
74
|
+
// tears the old loop down and starts a fresh one. Resetting the counter and
|
|
75
|
+
// calling loadOrder once would fetch a single time and then sit on the
|
|
76
|
+
// spinner forever, because loadOrder is stable and the effect never re-runs.
|
|
77
|
+
const [pollCycle, setPollCycle] = useState(0);
|
|
40
78
|
|
|
41
79
|
const loadOrder = useCallback(async (): Promise<EsimOrder | null> => {
|
|
42
80
|
try {
|
|
@@ -46,11 +84,21 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
46
84
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
47
85
|
order?: EsimOrder;
|
|
48
86
|
error?: string;
|
|
87
|
+
code?: string;
|
|
49
88
|
};
|
|
50
89
|
if (!res.ok || !payload.order) {
|
|
90
|
+
// 404 and 403 are settled answers: the order is unknown, or it is not
|
|
91
|
+
// this account's. Retrying returns the same status, and a spinner over
|
|
92
|
+
// the top of it is a lie about what the page is waiting for.
|
|
93
|
+
const terminal = res.status === 404 || res.status === 403 || payload.code === "not_found";
|
|
51
94
|
setState({
|
|
52
95
|
phase: "error",
|
|
53
|
-
|
|
96
|
+
terminal,
|
|
97
|
+
message:
|
|
98
|
+
payload.error ??
|
|
99
|
+
(terminal
|
|
100
|
+
? "We can't find this order on your account."
|
|
101
|
+
: "We could not load your eSIM details right now."),
|
|
54
102
|
});
|
|
55
103
|
return null;
|
|
56
104
|
}
|
|
@@ -59,6 +107,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
59
107
|
} catch {
|
|
60
108
|
setState({
|
|
61
109
|
phase: "error",
|
|
110
|
+
terminal: false,
|
|
62
111
|
message: "We could not reach your eSIM details. Check your connection and try again.",
|
|
63
112
|
});
|
|
64
113
|
return null;
|
|
@@ -73,10 +122,16 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
73
122
|
const order = await loadOrder();
|
|
74
123
|
if (cancelled) return;
|
|
75
124
|
const stillWorking = order?.status === "pending" || order?.status === "provisioning";
|
|
76
|
-
if (stillWorking
|
|
125
|
+
if (!stillWorking) return;
|
|
126
|
+
if (pollsRef.current < MAX_POLLS) {
|
|
77
127
|
pollsRef.current += 1;
|
|
78
128
|
timer = setTimeout(tick, POLL_INTERVAL_MS);
|
|
129
|
+
return;
|
|
79
130
|
}
|
|
131
|
+
// Out of polls and still not ready. Stop the spinner and say so — it has
|
|
132
|
+
// now taken materially longer than provisioning normally does, and
|
|
133
|
+
// "we're preparing your eSIM" under an animation is no longer true.
|
|
134
|
+
setState({ phase: "stalled", order });
|
|
80
135
|
}
|
|
81
136
|
|
|
82
137
|
void tick();
|
|
@@ -84,7 +139,48 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
84
139
|
cancelled = true;
|
|
85
140
|
if (timer) clearTimeout(timer);
|
|
86
141
|
};
|
|
87
|
-
}, [loadOrder]);
|
|
142
|
+
}, [loadOrder, pollCycle]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Ask the server to email this eSIM, once, the moment it is ready.
|
|
146
|
+
*
|
|
147
|
+
* The page has always promised "we'll email you the QR code" and, until
|
|
148
|
+
* there was a sender, nothing was ever sent. The request is fired from here
|
|
149
|
+
* rather than from fulfilment because this is the first place that knows the
|
|
150
|
+
* order reached `ready`; the route itself carries the ownership check and
|
|
151
|
+
* the send-once marker, so a reload does not produce a second copy.
|
|
152
|
+
*/
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (state.phase !== "ready" || state.order.status !== "ready") return;
|
|
155
|
+
if (emailAskedRef.current) return;
|
|
156
|
+
emailAskedRef.current = true;
|
|
157
|
+
|
|
158
|
+
let cancelled = false;
|
|
159
|
+
setEmail({ phase: "sending" });
|
|
160
|
+
void (async () => {
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}/email`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
});
|
|
165
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
166
|
+
sent?: boolean;
|
|
167
|
+
reason?: string;
|
|
168
|
+
};
|
|
169
|
+
if (cancelled) return;
|
|
170
|
+
if (res.ok && payload.sent) {
|
|
171
|
+
setEmail({ phase: "sent" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setEmail({ phase: "unavailable", reason: payload.reason ?? "unavailable" });
|
|
175
|
+
} catch {
|
|
176
|
+
if (!cancelled) setEmail({ phase: "unavailable", reason: "network" });
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
cancelled = true;
|
|
182
|
+
};
|
|
183
|
+
}, [state, orderId]);
|
|
88
184
|
|
|
89
185
|
async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
|
|
90
186
|
e.preventDefault();
|
|
@@ -120,11 +216,10 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
120
216
|
}
|
|
121
217
|
}
|
|
122
218
|
|
|
123
|
-
const order =
|
|
219
|
+
const order =
|
|
220
|
+
state.phase === "ready" || state.phase === "stalled" ? state.order : null;
|
|
124
221
|
const ready = order?.status === "ready";
|
|
125
|
-
const manualCode =
|
|
126
|
-
order?.activationCode ??
|
|
127
|
-
(order?.smdpAddress && order?.matchingId ? `LPA:1$${order.smdpAddress}$${order.matchingId}` : null);
|
|
222
|
+
const manualCode = order ? manualActivationCode(order) : null;
|
|
128
223
|
|
|
129
224
|
return (
|
|
130
225
|
<div className="mx-auto max-w-lg px-4 py-16">
|
|
@@ -150,27 +245,68 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
150
245
|
{state.phase === "error" && (
|
|
151
246
|
<>
|
|
152
247
|
<p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
|
|
153
|
-
We couldn
|
|
248
|
+
{state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}
|
|
154
249
|
</p>
|
|
155
250
|
<p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
|
|
156
251
|
<p className="text-sand-400 text-xs mb-4">
|
|
157
252
|
Your payment is safe and your order number is above. Quote it if you contact us.
|
|
158
253
|
</p>
|
|
254
|
+
<div className="flex items-center justify-center gap-3">
|
|
255
|
+
{/* No "Try again" on a terminal answer: the same request would
|
|
256
|
+
return the same status, and a button that cannot work is
|
|
257
|
+
worse than no button. */}
|
|
258
|
+
{!state.terminal && (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => {
|
|
262
|
+
pollsRef.current = 0;
|
|
263
|
+
setState({ phase: "loading" });
|
|
264
|
+
void loadOrder();
|
|
265
|
+
}}
|
|
266
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
267
|
+
>
|
|
268
|
+
Try again
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
271
|
+
<a
|
|
272
|
+
href="/contact"
|
|
273
|
+
className={
|
|
274
|
+
state.terminal
|
|
275
|
+
? "rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
276
|
+
: "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"
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
Contact support
|
|
280
|
+
</a>
|
|
281
|
+
</div>
|
|
282
|
+
</>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{state.phase === "stalled" && (
|
|
286
|
+
<>
|
|
287
|
+
<p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
|
|
288
|
+
This is taking longer than it should
|
|
289
|
+
</p>
|
|
290
|
+
<p className="text-sand-600 dark:text-sand-400 text-sm mb-4">
|
|
291
|
+
Your payment went through and your order is queued, but the eSIM isn't ready
|
|
292
|
+
yet. We'll email your QR code as soon as it is — you don't have to wait
|
|
293
|
+
here.
|
|
294
|
+
</p>
|
|
159
295
|
<div className="flex items-center justify-center gap-3">
|
|
160
296
|
<button
|
|
161
297
|
type="button"
|
|
162
298
|
onClick={() => {
|
|
163
299
|
pollsRef.current = 0;
|
|
164
300
|
setState({ phase: "loading" });
|
|
165
|
-
|
|
301
|
+
setPollCycle((n) => n + 1);
|
|
166
302
|
}}
|
|
167
|
-
className="rounded-full
|
|
303
|
+
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
304
|
>
|
|
169
|
-
|
|
305
|
+
Check again
|
|
170
306
|
</button>
|
|
171
307
|
<a
|
|
172
308
|
href="/contact"
|
|
173
|
-
className="rounded-full
|
|
309
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
174
310
|
>
|
|
175
311
|
Contact support
|
|
176
312
|
</a>
|
|
@@ -178,7 +314,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
178
314
|
</>
|
|
179
315
|
)}
|
|
180
316
|
|
|
181
|
-
{order && !ready && order.status !== "failed" && (
|
|
317
|
+
{state.phase === "ready" && order && !ready && order.status !== "failed" && (
|
|
182
318
|
<>
|
|
183
319
|
<div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
|
|
184
320
|
<p className="text-sand-600 dark:text-sand-400 text-sm mb-2">
|
|
@@ -234,6 +370,32 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
234
370
|
)}
|
|
235
371
|
</div>
|
|
236
372
|
|
|
373
|
+
{/* What happened to the email, in one line. The page promises a copy by
|
|
374
|
+
email in two other places; if none can be sent, it has to say so here
|
|
375
|
+
rather than let the customer navigate away expecting one. */}
|
|
376
|
+
{ready && email.phase !== "idle" && (
|
|
377
|
+
<p className="mb-4 text-center text-xs text-sand-500">
|
|
378
|
+
{email.phase === "sending" && "Emailing you a copy…"}
|
|
379
|
+
{email.phase === "sent" && "We've emailed a copy to the address that paid."}
|
|
380
|
+
{email.phase === "unavailable" &&
|
|
381
|
+
(email.reason === "unconfigured" || email.reason === "no-from-address"
|
|
382
|
+
? "Email delivery isn't set up on this store, so keep this page — everything you need to install is on it."
|
|
383
|
+
: "We couldn't email you a copy. Everything you need to install is on this page.")}
|
|
384
|
+
</p>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* One tap, no typing — iOS 17.4+ only, and harmless everywhere else:
|
|
388
|
+
a phone that cannot handle the link opens a page that explains the
|
|
389
|
+
manual route, which is the next card down. */}
|
|
390
|
+
{ready && manualCode && (
|
|
391
|
+
<a
|
|
392
|
+
href={directInstallUrl(manualCode)}
|
|
393
|
+
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"
|
|
394
|
+
>
|
|
395
|
+
Install this eSIM on this iPhone
|
|
396
|
+
</a>
|
|
397
|
+
)}
|
|
398
|
+
|
|
237
399
|
{/* Manual install — always available once we know the credentials, because
|
|
238
400
|
a QR is useless on the one phone the customer is reading this on. */}
|
|
239
401
|
{ready && (order?.smdpAddress || manualCode) && (
|
|
@@ -276,6 +438,32 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
276
438
|
{copied ? "Copied" : "Copy activation string"}
|
|
277
439
|
</button>
|
|
278
440
|
)}
|
|
441
|
+
|
|
442
|
+
{/* The steps in words. A customer whose camera cannot reach a second
|
|
443
|
+
screen, and whose phone is too old for the one-tap link, has the
|
|
444
|
+
credentials above and no idea where to type them. */}
|
|
445
|
+
<div className="mt-6 grid gap-5 border-t border-sand-200 pt-5 dark:border-sand-800">
|
|
446
|
+
<div>
|
|
447
|
+
<p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
|
|
448
|
+
On iPhone
|
|
449
|
+
</p>
|
|
450
|
+
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
451
|
+
{IPHONE_INSTALL_STEPS.map((step) => (
|
|
452
|
+
<li key={step}>{step}</li>
|
|
453
|
+
))}
|
|
454
|
+
</ol>
|
|
455
|
+
</div>
|
|
456
|
+
<div>
|
|
457
|
+
<p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
|
|
458
|
+
On Android
|
|
459
|
+
</p>
|
|
460
|
+
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
461
|
+
{ANDROID_INSTALL_STEPS.map((step) => (
|
|
462
|
+
<li key={step}>{step}</li>
|
|
463
|
+
))}
|
|
464
|
+
</ol>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
279
467
|
</div>
|
|
280
468
|
)}
|
|
281
469
|
|
|
@@ -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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/orders/[orderId]/email
|
|
3
|
+
*
|
|
4
|
+
* Send the "your eSIM is ready" email for one order, to the address that paid
|
|
5
|
+
* for it. Called by the activation page the moment fulfilment reports `ready`,
|
|
6
|
+
* which is the first instant the email has anything true to say.
|
|
7
|
+
*
|
|
8
|
+
* Three things this route refuses to do:
|
|
9
|
+
*
|
|
10
|
+
* - send for an order that is not ready. An email whose QR code is a blank
|
|
11
|
+
* square is worse than no email;
|
|
12
|
+
* - send twice. The marker lives in Stripe session metadata, because that is
|
|
13
|
+
* the only durable store this storefront has;
|
|
14
|
+
* - fail the order because email is not configured. A storefront with no
|
|
15
|
+
* Resend key sells eSIMs perfectly well; it answers 200 with
|
|
16
|
+
* `sent: false` and a reason, and the page says so in a sentence.
|
|
17
|
+
*
|
|
18
|
+
* It is guarded by the same auth + ownership check as every other
|
|
19
|
+
* order-scoped route, so a session id alone cannot make this storefront send
|
|
20
|
+
* mail to the address on someone else's order.
|
|
21
|
+
*/
|
|
22
|
+
import { NextResponse } from "next/server";
|
|
23
|
+
import { markQrEmailSent, orderPurchaserEmail, qrEmailSentAt } from "@/lib/checkout-order-claim";
|
|
24
|
+
import { guardOrderRoute, fulfilmentFailureResponse } from "@/lib/order-route-guard";
|
|
25
|
+
import { deliverQrEmail } from "@/lib/qr-delivery-email";
|
|
26
|
+
import { fetchOrder } from "@/vendor/carrier/client";
|
|
27
|
+
|
|
28
|
+
export const dynamic = "force-dynamic";
|
|
29
|
+
|
|
30
|
+
interface Context {
|
|
31
|
+
params: Promise<{ orderId: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function POST(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
35
|
+
const guard = await guardOrderRoute(ctx.params);
|
|
36
|
+
if (!guard.ok) return guard.response;
|
|
37
|
+
const { orderId } = guard;
|
|
38
|
+
|
|
39
|
+
const already = await qrEmailSentAt(orderId);
|
|
40
|
+
if (already) {
|
|
41
|
+
return NextResponse.json({ sent: true, deduplicated: true, sentAt: already });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lookup = await fetchOrder(orderId);
|
|
45
|
+
if (!lookup.ok) {
|
|
46
|
+
return fulfilmentFailureResponse(lookup.reason, "send your eSIM email", orderId);
|
|
47
|
+
}
|
|
48
|
+
if (lookup.order.status !== "ready") {
|
|
49
|
+
return NextResponse.json(
|
|
50
|
+
{ sent: false, reason: "not_ready", status: lookup.order.status },
|
|
51
|
+
{ status: 409 },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const to = await orderPurchaserEmail(orderId);
|
|
56
|
+
if (!to) {
|
|
57
|
+
// No address on the payment means nowhere to send. That is a fact about
|
|
58
|
+
// the order, not a failure of this storefront, and the page has the same
|
|
59
|
+
// QR on screen regardless.
|
|
60
|
+
return NextResponse.json({ sent: false, reason: "no_recipient" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await deliverQrEmail(lookup.order, to);
|
|
64
|
+
if (!result.sent) {
|
|
65
|
+
if (result.detail) {
|
|
66
|
+
console.error(`[email] QR delivery for ${orderId} failed (${result.reason}): ${result.detail}`);
|
|
67
|
+
}
|
|
68
|
+
return NextResponse.json({ sent: false, reason: result.reason });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await markQrEmailSent(orderId, new Date().toISOString());
|
|
72
|
+
return NextResponse.json({ sent: true, withQrImage: result.withQrImage });
|
|
73
|
+
}
|