@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/dist/{chunk-KYS5GHJV.js → chunk-625SBKRG.js} +679 -10
- package/dist/chunk-625SBKRG.js.map +1 -0
- package/dist/cli.js +23 -11
- package/dist/cli.js.map +1 -1
- package/dist/index.js +115 -514
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/plugin/carrier/.claude-plugin/plugin.json +1 -1
- package/templates/storefront/package-lock.json +47 -47
- package/templates/storefront/package.json +1 -1
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +226 -14
- package/templates/storefront/src/app/api/checkout/guest/route.ts +44 -13
- package/templates/storefront/src/app/api/orders/[orderId]/route.ts +69 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +29 -8
- package/templates/storefront/src/app/dashboard/page.tsx +89 -10
- package/templates/storefront/src/lib/checkout-order-claim.ts +13 -0
- package/templates/storefront/src/vendor/carrier/catalog-guard.ts +110 -0
- package/templates/storefront/src/vendor/carrier/client.ts +110 -13
- package/templates/storefront/src/vendor/carrier/types.ts +37 -0
- package/dist/chunk-KYS5GHJV.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carrierllc/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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.
|
|
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 (
|
|
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.
|
|
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.
|
|
3043
|
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.
|
|
3044
|
-
"integrity": "sha512-
|
|
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.
|
|
3092
|
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.
|
|
3093
|
-
"integrity": "sha512-
|
|
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.
|
|
3108
|
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.
|
|
3109
|
-
"integrity": "sha512-
|
|
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.
|
|
3124
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.
|
|
3125
|
-
"integrity": "sha512-
|
|
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.
|
|
3140
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.
|
|
3141
|
-
"integrity": "sha512-
|
|
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.
|
|
3156
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.
|
|
3157
|
-
"integrity": "sha512-
|
|
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.
|
|
3172
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.
|
|
3173
|
-
"integrity": "sha512-
|
|
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.
|
|
3188
|
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.
|
|
3189
|
-
"integrity": "sha512-
|
|
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.
|
|
3204
|
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.
|
|
3205
|
-
"integrity": "sha512-
|
|
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.
|
|
4089
|
-
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.
|
|
4090
|
-
"integrity": "sha512-
|
|
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.
|
|
9625
|
-
"resolved": "https://registry.npmjs.org/next/-/next-16.3.
|
|
9626
|
-
"integrity": "sha512-
|
|
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.
|
|
9630
|
-
"@swc/helpers": "0.5.
|
|
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.
|
|
9644
|
-
"@next/swc-darwin-x64": "16.3.
|
|
9645
|
-
"@next/swc-linux-arm64-gnu": "16.3.
|
|
9646
|
-
"@next/swc-linux-arm64-musl": "16.3.
|
|
9647
|
-
"@next/swc-linux-x64-gnu": "16.3.
|
|
9648
|
-
"@next/swc-linux-x64-musl": "16.3.
|
|
9649
|
-
"@next/swc-win32-arm64-msvc": "16.3.
|
|
9650
|
-
"@next/swc-win32-x64-msvc": "16.3.
|
|
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.
|
|
10227
|
-
"resolved": "https://registry.npmjs.org/qs/-/qs-6.
|
|
10228
|
-
"integrity": "sha512-
|
|
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": {
|
|
@@ -4,21 +4,87 @@
|
|
|
4
4
|
*
|
|
5
5
|
* MANGO-8/9: 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.
|
|
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"
|
|
134
|
+
<span className="text-2xl">{ready ? "✓" : "•"}</span>
|
|
53
135
|
</div>
|
|
54
|
-
<h1 className="text-2xl font-bold text-sand-900 dark:text-sand-50">
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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'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'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'll email you the QR code when it'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'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'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 {
|
|
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
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
115
|
+
{ error: "We could not start checkout. Please try again in a moment.", code: "stripe_error" },
|
|
85
116
|
{ status: 502 },
|
|
86
117
|
);
|
|
87
118
|
}
|