@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.
Files changed (39) hide show
  1. package/dist/{chunk-EHGZRVSO.js → chunk-ZUUC3J4J.js} +26 -3
  2. package/dist/chunk-ZUUC3J4J.js.map +1 -0
  3. package/dist/cli.js +397 -16
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +1 -1
  7. package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
  8. package/plugin/carrier/skills/storefront/SKILL.md +2 -2
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +205 -17
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
  12. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
  13. package/templates/storefront/src/app/api/orders/[orderId]/email/route.ts +73 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
  15. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  16. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  17. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  18. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  19. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  20. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  21. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  22. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  24. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  25. package/templates/storefront/src/brand.config.ts +19 -0
  26. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  27. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  28. package/templates/storefront/src/lib/checkout-order-claim.ts +113 -8
  29. package/templates/storefront/src/lib/esim-install.ts +74 -0
  30. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  31. package/templates/storefront/src/lib/qr-delivery-email.ts +106 -0
  32. package/templates/storefront/src/middleware.ts +1 -1
  33. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  34. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  35. package/templates/storefront/src/vendor/email/qr-delivery.ts +254 -0
  36. package/templates/storefront/src/vendor/email/resend.ts +140 -0
  37. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  38. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  39. package/dist/chunk-EHGZRVSO.js.map +0 -1
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ import {
65
65
  usageOverPeriodParams,
66
66
  verifyStorefront,
67
67
  withOcsListSummary
68
- } from "./chunk-EHGZRVSO.js";
68
+ } from "./chunk-ZUUC3J4J.js";
69
69
  import "./chunk-SHKKVIIA.js";
70
70
 
71
71
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carrierllc/mcp",
3
- "version": "0.11.17",
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 (Mango template) via @carrierllc/cli, and keeps its plans in sync with the Carrier fleet. Use to launch or rebrand a 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 (Mango-derived template) scaffolded by `@carrierllc/cli`. Its single white-label surface is `src/brand.config.ts` (name, tagline, colors, domain, support, social).
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. Check there are no leftover "Mango" strings.
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 (Mango template, white-labeled) on top of your Carrier fleet.
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: Travel eSIM that just works.
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
- * MANGO-8/9: Shown after sign-up/sign-in on /activate/[orderId].
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
- * - Falls back to the SM-DP+ address and matching ID for manual entry whenever
9
- * the QR image is missing — every phone accepts those typed by hand.
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
- | { phase: "error"; message: string };
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
- message: payload.error ?? "We could not load your eSIM details right now.",
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 && pollsRef.current < MAX_POLLS) {
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 = state.phase === "ready" ? state.order : null;
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&apos;t load your eSIM
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&apos;t ready
292
+ yet. We&apos;ll email your QR code as soon as it is — you don&apos;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
- void loadOrder();
301
+ setPollCycle((n) => n + 1);
166
302
  }}
167
- className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
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
- Try again
305
+ Check again
170
306
  </button>
171
307
  <a
172
308
  href="/contact"
173
- 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"
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
- * MANGO-8/9: Post-purchase eSIM activation page.
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
- * MANGO-8/9: Purchase-first flow — creates a Stripe Checkout session for
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: { url: string } — Stripe-hosted checkout URL
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
- * The Stripe success_url encodes the templateId so the /checkout/success
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: { templateId?: string; channel?: string };
47
+ let body: GuestCheckoutBody;
27
48
  try {
28
- body = (await req.json()) as { templateId?: string; channel?: string };
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: successUrl,
83
- cancel_url: cancelUrl,
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 { url: string };
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
+ }