@carrierllc/mcp 0.10.7 → 0.11.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carrierllc/mcp",
3
- "version": "0.10.7",
3
+ "version": "0.11.4",
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)",
@@ -48,7 +48,7 @@
48
48
  "zod": "^4.4.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@cloudflare/workers-types": "^5.20260819.1",
51
+ "@cloudflare/workers-types": "^5.20260903.1",
52
52
  "@types/node": "^26.1.2",
53
53
  "@typescript-eslint/eslint-plugin": "^8.59.4",
54
54
  "@typescript-eslint/parser": "^8.67.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carrier",
3
3
  "version": "0.1.4",
4
- "description": "Natural-language control of your MVNO/eSIM fleet — the Stripe of telecom. Provision eSIMs, manage subscribers, packages, billing, usage, SMS, churn, and fleet health, and roll out a white-labeled storefront. Wraps the Carrier MCP (122 tools over OAuth 2.1).",
4
+ "description": "Natural-language control of your MVNO/eSIM fleet — the Stripe of telecom. Provision eSIMs, manage subscribers, packages, billing, usage, SMS, churn, and fleet health, and roll out a white-labeled storefront. Wraps the Carrier MCP (125 tools over OAuth 2.1).",
5
5
  "author": {
6
6
  "name": "Lifecycle Innovations Limited",
7
7
  "url": "https://carrier.llc"
@@ -12,7 +12,7 @@
12
12
  "clsx": "^2.1.1",
13
13
  "framer-motion": "^11.15.0",
14
14
  "geist": "^1.3.1",
15
- "next": "^16.3.0",
15
+ "next": "^16.3.1",
16
16
  "react": "^19.0.0",
17
17
  "react-dom": "^19.0.0",
18
18
  "tailwind-merge": "^2.6.0"
@@ -3039,9 +3039,9 @@
3039
3039
  }
3040
3040
  },
3041
3041
  "node_modules/@next/env": {
3042
- "version": "16.3.0",
3043
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
3044
- "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
3042
+ "version": "16.3.1",
3043
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz",
3044
+ "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==",
3045
3045
  "license": "MIT"
3046
3046
  },
3047
3047
  "node_modules/@next/eslint-plugin-next": {
@@ -3088,9 +3088,9 @@
3088
3088
  }
3089
3089
  },
3090
3090
  "node_modules/@next/swc-darwin-arm64": {
3091
- "version": "16.3.0",
3092
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
3093
- "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
3091
+ "version": "16.3.1",
3092
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz",
3093
+ "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==",
3094
3094
  "cpu": [
3095
3095
  "arm64"
3096
3096
  ],
@@ -3104,9 +3104,9 @@
3104
3104
  }
3105
3105
  },
3106
3106
  "node_modules/@next/swc-darwin-x64": {
3107
- "version": "16.3.0",
3108
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
3109
- "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
3107
+ "version": "16.3.1",
3108
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz",
3109
+ "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==",
3110
3110
  "cpu": [
3111
3111
  "x64"
3112
3112
  ],
@@ -3120,9 +3120,9 @@
3120
3120
  }
3121
3121
  },
3122
3122
  "node_modules/@next/swc-linux-arm64-gnu": {
3123
- "version": "16.3.0",
3124
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
3125
- "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
3123
+ "version": "16.3.1",
3124
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz",
3125
+ "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==",
3126
3126
  "cpu": [
3127
3127
  "arm64"
3128
3128
  ],
@@ -3136,9 +3136,9 @@
3136
3136
  }
3137
3137
  },
3138
3138
  "node_modules/@next/swc-linux-arm64-musl": {
3139
- "version": "16.3.0",
3140
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
3141
- "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
3139
+ "version": "16.3.1",
3140
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz",
3141
+ "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==",
3142
3142
  "cpu": [
3143
3143
  "arm64"
3144
3144
  ],
@@ -3152,9 +3152,9 @@
3152
3152
  }
3153
3153
  },
3154
3154
  "node_modules/@next/swc-linux-x64-gnu": {
3155
- "version": "16.3.0",
3156
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
3157
- "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
3155
+ "version": "16.3.1",
3156
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz",
3157
+ "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==",
3158
3158
  "cpu": [
3159
3159
  "x64"
3160
3160
  ],
@@ -3168,9 +3168,9 @@
3168
3168
  }
3169
3169
  },
3170
3170
  "node_modules/@next/swc-linux-x64-musl": {
3171
- "version": "16.3.0",
3172
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
3173
- "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
3171
+ "version": "16.3.1",
3172
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz",
3173
+ "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==",
3174
3174
  "cpu": [
3175
3175
  "x64"
3176
3176
  ],
@@ -3184,9 +3184,9 @@
3184
3184
  }
3185
3185
  },
3186
3186
  "node_modules/@next/swc-win32-arm64-msvc": {
3187
- "version": "16.3.0",
3188
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
3189
- "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
3187
+ "version": "16.3.1",
3188
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz",
3189
+ "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==",
3190
3190
  "cpu": [
3191
3191
  "arm64"
3192
3192
  ],
@@ -3200,9 +3200,9 @@
3200
3200
  }
3201
3201
  },
3202
3202
  "node_modules/@next/swc-win32-x64-msvc": {
3203
- "version": "16.3.0",
3204
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
3205
- "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
3203
+ "version": "16.3.1",
3204
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz",
3205
+ "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==",
3206
3206
  "cpu": [
3207
3207
  "x64"
3208
3208
  ],
@@ -4085,9 +4085,9 @@
4085
4085
  "license": "MIT"
4086
4086
  },
4087
4087
  "node_modules/@swc/helpers": {
4088
- "version": "0.5.15",
4089
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
4090
- "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
4088
+ "version": "0.5.23",
4089
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
4090
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
4091
4091
  "license": "Apache-2.0",
4092
4092
  "dependencies": {
4093
4093
  "tslib": "^2.8.0"
@@ -9621,13 +9621,13 @@
9621
9621
  }
9622
9622
  },
9623
9623
  "node_modules/next": {
9624
- "version": "16.3.0",
9625
- "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
9626
- "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
9624
+ "version": "16.3.1",
9625
+ "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
9626
+ "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
9627
9627
  "license": "MIT",
9628
9628
  "dependencies": {
9629
- "@next/env": "16.3.0",
9630
- "@swc/helpers": "0.5.15",
9629
+ "@next/env": "16.3.1",
9630
+ "@swc/helpers": "0.5.23",
9631
9631
  "baseline-browser-mapping": "^2.9.19",
9632
9632
  "caniuse-lite": "^1.0.30001579",
9633
9633
  "postcss": "8.5.23",
@@ -9640,14 +9640,14 @@
9640
9640
  "node": ">=20.9.0"
9641
9641
  },
9642
9642
  "optionalDependencies": {
9643
- "@next/swc-darwin-arm64": "16.3.0",
9644
- "@next/swc-darwin-x64": "16.3.0",
9645
- "@next/swc-linux-arm64-gnu": "16.3.0",
9646
- "@next/swc-linux-arm64-musl": "16.3.0",
9647
- "@next/swc-linux-x64-gnu": "16.3.0",
9648
- "@next/swc-linux-x64-musl": "16.3.0",
9649
- "@next/swc-win32-arm64-msvc": "16.3.0",
9650
- "@next/swc-win32-x64-msvc": "16.3.0",
9643
+ "@next/swc-darwin-arm64": "16.3.1",
9644
+ "@next/swc-darwin-x64": "16.3.1",
9645
+ "@next/swc-linux-arm64-gnu": "16.3.1",
9646
+ "@next/swc-linux-arm64-musl": "16.3.1",
9647
+ "@next/swc-linux-x64-gnu": "16.3.1",
9648
+ "@next/swc-linux-x64-musl": "16.3.1",
9649
+ "@next/swc-win32-arm64-msvc": "16.3.1",
9650
+ "@next/swc-win32-x64-msvc": "16.3.1",
9651
9651
  "sharp": "^0.35.3"
9652
9652
  },
9653
9653
  "peerDependencies": {
@@ -10223,9 +10223,9 @@
10223
10223
  }
10224
10224
  },
10225
10225
  "node_modules/qs": {
10226
- "version": "6.15.3",
10227
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
10228
- "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
10226
+ "version": "6.16.0",
10227
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
10228
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
10229
10229
  "dev": true,
10230
10230
  "license": "BSD-3-Clause",
10231
10231
  "dependencies": {
@@ -17,7 +17,7 @@
17
17
  "clsx": "^2.1.1",
18
18
  "framer-motion": "^11.15.0",
19
19
  "geist": "^1.3.1",
20
- "next": "^16.3.0",
20
+ "next": "^16.3.1",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0",
23
23
  "tailwind-merge": "^2.6.0"
@@ -4,21 +4,87 @@
4
4
  *
5
5
  * MANGO-8/9: Shown after sign-up/sign-in on /activate/[orderId].
6
6
  *
7
- * - Displays the eSIM QR code (once available from the API).
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.
10
+ * - Says plainly when it cannot read the order at all. The page used to print
11
+ * a fixed reassurance that a QR code was on its way, and then never
12
+ * ask anyone anything, which reads as progress and is not.
8
13
  * - Optionally collects phone number / WhatsApp for eSIM delivery notifications.
9
14
  * Phone is NEVER required — user can skip.
10
15
  */
11
- import { useState, FormEvent } from "react";
16
+ import { useCallback, useEffect, useRef, useState, FormEvent } from "react";
17
+ import type { EsimOrder } from "@/vendor/carrier/types";
12
18
 
13
19
  interface Props {
14
20
  orderId: string;
15
21
  }
16
22
 
23
+ type LoadState =
24
+ | { phase: "loading" }
25
+ | { phase: "ready"; order: EsimOrder }
26
+ | { phase: "error"; message: string };
27
+
28
+ /** Provisioning is usually seconds. Poll a bounded number of times, then stop. */
29
+ const POLL_INTERVAL_MS = 4000;
30
+ const MAX_POLLS = 15;
31
+
17
32
  export function ActivateClient({ orderId }: Props) {
18
33
  const [phone, setPhone] = useState("");
19
34
  const [submitted, setSubmitted] = useState(false);
20
35
  const [saving, setSaving] = useState(false);
21
36
  const [saveError, setSaveError] = useState<string | null>(null);
37
+ const [state, setState] = useState<LoadState>({ phase: "loading" });
38
+ const [copied, setCopied] = useState(false);
39
+ const pollsRef = useRef(0);
40
+
41
+ const loadOrder = useCallback(async (): Promise<EsimOrder | null> => {
42
+ try {
43
+ const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}`, {
44
+ cache: "no-store",
45
+ });
46
+ const payload = (await res.json().catch(() => ({}))) as {
47
+ order?: EsimOrder;
48
+ error?: string;
49
+ };
50
+ if (!res.ok || !payload.order) {
51
+ setState({
52
+ phase: "error",
53
+ message: payload.error ?? "We could not load your eSIM details right now.",
54
+ });
55
+ return null;
56
+ }
57
+ setState({ phase: "ready", order: payload.order });
58
+ return payload.order;
59
+ } catch {
60
+ setState({
61
+ phase: "error",
62
+ message: "We could not reach your eSIM details. Check your connection and try again.",
63
+ });
64
+ return null;
65
+ }
66
+ }, [orderId]);
67
+
68
+ useEffect(() => {
69
+ let cancelled = false;
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+
72
+ async function tick() {
73
+ const order = await loadOrder();
74
+ if (cancelled) return;
75
+ const stillWorking = order?.status === "pending" || order?.status === "provisioning";
76
+ if (stillWorking && pollsRef.current < MAX_POLLS) {
77
+ pollsRef.current += 1;
78
+ timer = setTimeout(tick, POLL_INTERVAL_MS);
79
+ }
80
+ }
81
+
82
+ void tick();
83
+ return () => {
84
+ cancelled = true;
85
+ if (timer) clearTimeout(timer);
86
+ };
87
+ }, [loadOrder]);
22
88
 
23
89
  async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
24
90
  e.preventDefault();
@@ -44,27 +110,175 @@ export function ActivateClient({ orderId }: Props) {
44
110
  }
45
111
  }
46
112
 
113
+ async function copyActivationCode(code: string) {
114
+ try {
115
+ await navigator.clipboard.writeText(code);
116
+ setCopied(true);
117
+ setTimeout(() => setCopied(false), 2000);
118
+ } catch {
119
+ setCopied(false);
120
+ }
121
+ }
122
+
123
+ const order = state.phase === "ready" ? state.order : null;
124
+ const ready = order?.status === "ready";
125
+ const manualCode =
126
+ order?.activationCode ??
127
+ (order?.smdpAddress && order?.matchingId ? `LPA:1$${order.smdpAddress}$${order.matchingId}` : null);
128
+
47
129
  return (
48
130
  <div className="mx-auto max-w-lg px-4 py-16">
49
131
  {/* Header */}
50
132
  <div className="text-center mb-10">
51
133
  <div className="inline-flex items-center justify-center size-14 rounded-full bg-emerald-500/10 border border-emerald-500/30 mb-4">
52
- <span className="text-2xl">✓</span>
134
+ <span className="text-2xl">{ready ? "✓" : "•"}</span>
53
135
  </div>
54
- <h1 className="text-2xl font-bold text-sand-900 dark:text-sand-50">Your eSIM is ready</h1>
136
+ <h1 className="text-2xl font-bold text-sand-900 dark:text-sand-50">
137
+ {ready ? "Your eSIM is ready" : "Setting up your eSIM"}
138
+ </h1>
55
139
  <p className="mt-2 text-sm text-sand-500">Order: {orderId}</p>
56
140
  </div>
57
141
 
58
- {/* QR Code placeholder */}
59
142
  <div className="rounded-2xl border border-sand-200 dark:border-sand-800 bg-white dark:bg-sand-900 p-8 text-center mb-8">
60
- <p className="text-sand-600 dark:text-sand-400 text-sm mb-2">
61
- Your QR code will appear here once your order is confirmed.
62
- </p>
63
- <p className="text-sand-400 text-xs">
64
- This usually takes less than a minute. Refresh if needed.
65
- </p>
143
+ {state.phase === "loading" && (
144
+ <>
145
+ <div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
146
+ <p className="text-sand-600 dark:text-sand-400 text-sm">Loading your eSIM…</p>
147
+ </>
148
+ )}
149
+
150
+ {state.phase === "error" && (
151
+ <>
152
+ <p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
153
+ We couldn&apos;t load your eSIM
154
+ </p>
155
+ <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
156
+ <p className="text-sand-400 text-xs mb-4">
157
+ Your payment is safe and your order number is above. Quote it if you contact us.
158
+ </p>
159
+ <div className="flex items-center justify-center gap-3">
160
+ <button
161
+ type="button"
162
+ onClick={() => {
163
+ pollsRef.current = 0;
164
+ setState({ phase: "loading" });
165
+ void loadOrder();
166
+ }}
167
+ className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
168
+ >
169
+ Try again
170
+ </button>
171
+ <a
172
+ 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"
174
+ >
175
+ Contact support
176
+ </a>
177
+ </div>
178
+ </>
179
+ )}
180
+
181
+ {order && !ready && order.status !== "failed" && (
182
+ <>
183
+ <div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
184
+ <p className="text-sand-600 dark:text-sand-400 text-sm mb-2">
185
+ We&apos;re preparing your eSIM. This usually takes less than a minute.
186
+ </p>
187
+ <p className="text-sand-400 text-xs">
188
+ You can leave this page — we&apos;ll email you the QR code when it&apos;s ready.
189
+ </p>
190
+ </>
191
+ )}
192
+
193
+ {order?.status === "failed" && (
194
+ <>
195
+ <p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
196
+ Your eSIM couldn&apos;t be set up
197
+ </p>
198
+ <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">
199
+ {order.failureMessage ??
200
+ "Something went wrong while activating your plan. Our team can sort this out for you."}
201
+ </p>
202
+ <a
203
+ href="/contact"
204
+ className="inline-block rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
205
+ >
206
+ Contact support
207
+ </a>
208
+ </>
209
+ )}
210
+
211
+ {ready && (
212
+ <>
213
+ {order?.qrCode ? (
214
+ /* The QR image is served by the fulfilment API; next/image would
215
+ need every possible operator origin allow-listed in
216
+ next.config, so this stays a plain img on purpose. */
217
+ // eslint-disable-next-line @next/next/no-img-element
218
+ <img
219
+ src={order.qrCode}
220
+ alt="eSIM activation QR code"
221
+ className="mx-auto mb-4 size-56 rounded-xl bg-white p-2"
222
+ />
223
+ ) : (
224
+ <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">
225
+ Your eSIM is active. Enter the details below on your phone to install it.
226
+ </p>
227
+ )}
228
+ <p className="text-sand-600 dark:text-sand-400 text-sm">
229
+ Scan this with your phone&apos;s camera, or add the eSIM manually with the details
230
+ below.
231
+ </p>
232
+ {order?.planName && <p className="mt-2 text-xs text-sand-400">{order.planName}</p>}
233
+ </>
234
+ )}
66
235
  </div>
67
236
 
237
+ {/* Manual install — always available once we know the credentials, because
238
+ a QR is useless on the one phone the customer is reading this on. */}
239
+ {ready && (order?.smdpAddress || manualCode) && (
240
+ <div className="rounded-2xl border border-sand-200 dark:border-sand-800 bg-white dark:bg-sand-900 p-6 mb-8">
241
+ <p className="text-sm font-medium text-sand-900 dark:text-sand-50 mb-3">
242
+ Install manually
243
+ </p>
244
+ <dl className="space-y-3 text-sm">
245
+ {order?.smdpAddress && (
246
+ <div>
247
+ <dt className="text-xs text-sand-500">SM-DP+ address</dt>
248
+ <dd className="font-mono break-all text-sand-900 dark:text-sand-50">
249
+ {order.smdpAddress}
250
+ </dd>
251
+ </div>
252
+ )}
253
+ {order?.matchingId && (
254
+ <div>
255
+ <dt className="text-xs text-sand-500">Activation code</dt>
256
+ <dd className="font-mono break-all text-sand-900 dark:text-sand-50">
257
+ {order.matchingId}
258
+ </dd>
259
+ </div>
260
+ )}
261
+ {order?.iccid && (
262
+ <div>
263
+ <dt className="text-xs text-sand-500">ICCID</dt>
264
+ <dd className="font-mono break-all text-sand-900 dark:text-sand-50">
265
+ {order.iccid}
266
+ </dd>
267
+ </div>
268
+ )}
269
+ </dl>
270
+ {manualCode && (
271
+ <button
272
+ type="button"
273
+ onClick={() => void copyActivationCode(manualCode)}
274
+ className="mt-4 w-full rounded-lg border border-sand-300 dark:border-sand-700 px-4 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
275
+ >
276
+ {copied ? "Copied" : "Copy activation string"}
277
+ </button>
278
+ )}
279
+ </div>
280
+ )}
281
+
68
282
  {/* Optional phone / WhatsApp collection */}
69
283
  <div className="rounded-2xl border border-sand-200 dark:border-sand-800 bg-white dark:bg-sand-900 p-6">
70
284
  <div className="flex items-start gap-3 mb-4">
@@ -107,9 +321,7 @@ export function ActivateClient({ orderId }: Props) {
107
321
  {saving ? "Saving…" : "Save"}
108
322
  </button>
109
323
  </div>
110
- {saveError && (
111
- <p className="text-xs text-red-300">{saveError}</p>
112
- )}
324
+ {saveError && <p className="text-xs text-red-300">{saveError}</p>}
113
325
  <button
114
326
  type="button"
115
327
  onClick={() => setSubmitted(true)}
@@ -4,20 +4,28 @@
4
4
  * MANGO-8/9: Purchase-first flow — creates a Stripe Checkout session for
5
5
  * unauthenticated users. No Clerk auth required.
6
6
  *
7
- * Body: { templateId: string }
7
+ * Body: { templateId: string, channel?: string }
8
8
  *
9
9
  * Returns: { url: string } — Stripe-hosted checkout URL
10
10
  *
11
11
  * The Stripe success_url encodes the templateId so the /checkout/success
12
12
  * page can display the correct plan and prompt sign-up / sign-in.
13
+ *
14
+ * The line item is built inline from the plan's own price (`price_data`, no
15
+ * Stripe price id), which means Stripe accepts whatever number we send. So the
16
+ * plan has to be real before we get here: with a live Stripe key, a catalog
17
+ * that fell back to sample plans is refused outright. Charging for
18
+ * "Visit — Calm" at EUR 4.99 when no such product exists is a real payment
19
+ * against nothing, and no webhook downstream can fulfil it.
13
20
  */
14
21
  import { NextRequest, NextResponse } from "next/server";
15
- import { fetchTemplates } from "@/vendor/carrier/client";
22
+ import { fetchTemplateCatalog } from "@/vendor/carrier/client";
23
+ import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
16
24
 
17
25
  export async function POST(req: NextRequest): Promise<NextResponse> {
18
- let body: { templateId?: string };
26
+ let body: { templateId?: string; channel?: string };
19
27
  try {
20
- body = (await req.json()) as { templateId?: string };
28
+ body = (await req.json()) as { templateId?: string; channel?: string };
21
29
  } catch {
22
30
  return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
23
31
  }
@@ -27,17 +35,32 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
27
35
  return NextResponse.json({ error: "templateId is required" }, { status: 400 });
28
36
  }
29
37
 
30
- // Validate the template exists
31
- const plans = await fetchTemplates();
32
- const plan = plans.find((p) => p.id === templateId);
33
- if (!plan) {
34
- return NextResponse.json({ error: "Plan not found" }, { status: 404 });
38
+ const channel = normalizeChannel(body.channel);
39
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
40
+ const catalog = await fetchTemplateCatalog();
41
+
42
+ const decision = decideGuestCheckout({
43
+ catalog,
44
+ templateId,
45
+ stripeConfigured: Boolean(stripeKey),
46
+ });
47
+ if (!decision.ok) {
48
+ if (decision.code === "catalog_degraded") {
49
+ // Loud on the server, calm to the customer. This is the state where a
50
+ // storefront is live, taking traffic, and cannot tell what it sells.
51
+ console.error(
52
+ `[checkout] refusing to sell from a sample catalog (reason=${catalog.reason ?? "unknown"}) — ` +
53
+ "NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed",
54
+ );
55
+ }
56
+ return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status });
35
57
  }
36
58
 
37
- const stripeKey = process.env.STRIPE_SECRET_KEY;
59
+ const plan = decision.plan;
60
+
38
61
  if (!stripeKey) {
39
62
  // No Stripe configured — return a mock URL so the storefront still builds
40
- // and renders without credentials (same pattern as fetchTemplates mock).
63
+ // and renders without credentials. No money moves on this path.
41
64
  const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
42
65
  const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`;
43
66
  return NextResponse.json({ url: successUrl });
@@ -65,6 +88,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
65
88
  // without it the plan is lost and the order is stuck "activating".
66
89
  "metadata[templateId]": templateId,
67
90
  "payment_intent_data[metadata][templateId]": templateId,
91
+ // Where the sale came from. Written alongside templateId and by the same
92
+ // reasoning: attribution recovered later from Stripe reports is guesswork,
93
+ // and the session is the only record that survives the redirect.
94
+ "metadata[channel]": channel,
95
+ "payment_intent_data[metadata][channel]": channel,
68
96
  // Allow guest checkout (no Stripe account required)
69
97
  "payment_method_collection": "always",
70
98
  });
@@ -79,9 +107,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
79
107
  });
80
108
 
81
109
  if (!resp.ok) {
82
- const err = (await resp.json()) as { error?: { message?: string } };
110
+ // Stripe's message names our parameters and our account. It goes to the
111
+ // log, not to the customer.
112
+ const err = (await resp.json().catch(() => ({}))) as { error?: { message?: string } };
113
+ console.error(`[checkout] Stripe session creation failed: ${err.error?.message ?? resp.status}`);
83
114
  return NextResponse.json(
84
- { error: err.error?.message ?? "Failed to create checkout session" },
115
+ { error: "We could not start checkout. Please try again in a moment.", code: "stripe_error" },
85
116
  { status: 502 },
86
117
  );
87
118
  }