@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a customer gets an eSIM onto a phone, in every form we can offer.
|
|
3
|
+
*
|
|
4
|
+
* This lives apart from the pages because the activate page and the delivery
|
|
5
|
+
* email have to say exactly the same thing. They did not, in the product this
|
|
6
|
+
* template grew out of: the page learned about one-tap install and the email
|
|
7
|
+
* kept telling people to scan a QR code on the device they were reading it on.
|
|
8
|
+
* Two copies of install instructions drift, and the copy that drifts is the one
|
|
9
|
+
* the customer is holding.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Both halves of the credential, as a phone's manual-entry form asks for them. */
|
|
13
|
+
export interface ManualInstallDetails {
|
|
14
|
+
/** e.g. consumer.rsp.example.com — the "SM-DP+ address" field. */
|
|
15
|
+
smdpAddress?: string;
|
|
16
|
+
/** e.g. K2-1A2B3C-4D5E6F — the "activation code" field. */
|
|
17
|
+
matchingId?: string;
|
|
18
|
+
/** The whole LPA string, which is what a QR code encodes. */
|
|
19
|
+
activationCode?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The full LPA string, from whichever pieces the operator supplied.
|
|
24
|
+
*
|
|
25
|
+
* `LPA:1$<smdp>$<matchingId>` is the format every modern handset accepts, so
|
|
26
|
+
* either the operator hands it over whole or we can rebuild it.
|
|
27
|
+
*/
|
|
28
|
+
export function manualActivationCode(details: ManualInstallDetails): string | null {
|
|
29
|
+
if (details.activationCode) return details.activationCode;
|
|
30
|
+
if (details.smdpAddress && details.matchingId) {
|
|
31
|
+
return `LPA:1$${details.smdpAddress}$${details.matchingId}`;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apple's universal eSIM install link — iOS 17.4 and later hand the activation
|
|
38
|
+
* string straight to Settings, so the customer never types anything.
|
|
39
|
+
*
|
|
40
|
+
* Nothing equivalent exists on Android: install there is Settings → Network →
|
|
41
|
+
* add eSIM → enter manually, which is what the written steps below are for.
|
|
42
|
+
* The link is harmless on a phone that cannot use it; it opens a page that
|
|
43
|
+
* explains the manual route.
|
|
44
|
+
*/
|
|
45
|
+
export function directInstallUrl(activationCode: string): string {
|
|
46
|
+
return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${encodeURIComponent(
|
|
47
|
+
activationCode,
|
|
48
|
+
)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The steps in words.
|
|
53
|
+
*
|
|
54
|
+
* A customer whose camera cannot reach a second screen, and whose phone is too
|
|
55
|
+
* old for the one-tap link, otherwise has two credentials and no idea where to
|
|
56
|
+
* type them. The SM-DP+ address and the activation code are named here exactly
|
|
57
|
+
* as the handset names them, because "paste your activation string" does not
|
|
58
|
+
* match any field on either operating system.
|
|
59
|
+
*/
|
|
60
|
+
export const IPHONE_INSTALL_STEPS: readonly string[] = [
|
|
61
|
+
"Open Settings, then Cellular (or Mobile Service).",
|
|
62
|
+
"Tap Add eSIM, then Use QR Code.",
|
|
63
|
+
"Tap Enter Details Manually.",
|
|
64
|
+
"Paste the SM-DP+ address and activation code from above.",
|
|
65
|
+
"Leave data roaming on — that is how the plan connects abroad.",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const ANDROID_INSTALL_STEPS: readonly string[] = [
|
|
69
|
+
"Open Settings, then Network & internet.",
|
|
70
|
+
"Tap SIMs, then Add SIM or Download a SIM instead.",
|
|
71
|
+
"Choose Need help? then Enter it manually.",
|
|
72
|
+
"Paste the activation code from above.",
|
|
73
|
+
"Turn on roaming for this SIM once it appears.",
|
|
74
|
+
];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate every order-scoped route handler stands behind.
|
|
3
|
+
*
|
|
4
|
+
* Signed in, and the order is one this user has claimed. Both checks belong
|
|
5
|
+
* here rather than in each handler because a route handler is reachable
|
|
6
|
+
* directly, and an ownership check that only three of four routes perform is
|
|
7
|
+
* the one an attacker uses.
|
|
8
|
+
*
|
|
9
|
+
* Refusals are deliberately indistinguishable: an order that does not exist
|
|
10
|
+
* and an order belonging to somebody else both answer 404, so the route cannot
|
|
11
|
+
* be used to find out which order ids are real.
|
|
12
|
+
*/
|
|
13
|
+
import { auth } from "@clerk/nextjs/server";
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
import { userOwnsOrder } from "@/lib/checkout-order-claim";
|
|
16
|
+
import type { OrderLookupError } from "@/vendor/carrier/client";
|
|
17
|
+
|
|
18
|
+
export type OrderRouteGuard =
|
|
19
|
+
| { ok: true; userId: string; orderId: string }
|
|
20
|
+
| { ok: false; response: NextResponse };
|
|
21
|
+
|
|
22
|
+
export async function guardOrderRoute(
|
|
23
|
+
params: Promise<{ orderId: string }>,
|
|
24
|
+
): Promise<OrderRouteGuard> {
|
|
25
|
+
const { userId } = await auth();
|
|
26
|
+
if (!userId) {
|
|
27
|
+
return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { orderId } = await params;
|
|
31
|
+
if (!orderId) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
response: NextResponse.json({ error: "orderId is required" }, { status: 400 }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!(await userOwnsOrder(userId, orderId))) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
response: NextResponse.json({ error: "Order not found" }, { status: 404 }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ok: true, userId, orderId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One fulfilment failure, as customer copy plus a status.
|
|
50
|
+
*
|
|
51
|
+
* `action` names the thing that did not happen ("read your usage", "cancel
|
|
52
|
+
* this plan"), because "something went wrong" tells the reader nothing about
|
|
53
|
+
* whether their eSIM is still working.
|
|
54
|
+
*/
|
|
55
|
+
export function fulfilmentFailureResponse(
|
|
56
|
+
reason: OrderLookupError,
|
|
57
|
+
action: string,
|
|
58
|
+
orderId: string,
|
|
59
|
+
): NextResponse {
|
|
60
|
+
if (reason === "not-found") {
|
|
61
|
+
return NextResponse.json({ error: "Order not found", code: "not_found" }, { status: 404 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (reason === "unconfigured") {
|
|
65
|
+
console.error(
|
|
66
|
+
`[orders] NEXT_PUBLIC_CARRIER_API_URL is unset — cannot ${action} for ${orderId}`,
|
|
67
|
+
);
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{
|
|
70
|
+
error: `We can't ${action} yet. Please contact support with your order number.`,
|
|
71
|
+
code: "fulfilment_unconfigured",
|
|
72
|
+
},
|
|
73
|
+
{ status: 503 },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.error(`[orders] failed to ${action} for ${orderId}: ${reason}`);
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{
|
|
80
|
+
error: `We couldn't ${action} right now. Please try again in a moment.`,
|
|
81
|
+
code: "fulfilment_unavailable",
|
|
82
|
+
},
|
|
83
|
+
{ status: 502 },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn one ready order into one delivered email.
|
|
3
|
+
*
|
|
4
|
+
* The interesting part is getting the QR out of whatever the operator's
|
|
5
|
+
* fulfilment handed us and into an attachment. `EsimOrder.qrCode` is
|
|
6
|
+
* documented as "a data: URI or an https URL" because both are things a
|
|
7
|
+
* fulfilment API plausibly returns, and neither can be dropped into an email
|
|
8
|
+
* as-is:
|
|
9
|
+
*
|
|
10
|
+
* - a `data:` URI in `<img src>` is stripped by Gmail and shows as a broken
|
|
11
|
+
* image, which is the single most expensive way for this email to fail;
|
|
12
|
+
* - an `https` URL survives, but only while that host is up and only if the
|
|
13
|
+
* reader's client loads remote images, which many do not by default.
|
|
14
|
+
*
|
|
15
|
+
* So both are normalised to bytes and attached with a `content_id`, and the
|
|
16
|
+
* HTML points at `cid:`. An attachment is part of the message; it cannot 404
|
|
17
|
+
* next month and it does not need remote images enabled.
|
|
18
|
+
*/
|
|
19
|
+
import brand from "@/brand.config";
|
|
20
|
+
import type { EsimOrder } from "@/vendor/carrier/types";
|
|
21
|
+
import { buildQrDeliveryEmail, type QrImage } from "@/vendor/email/qr-delivery";
|
|
22
|
+
import { emailConfigured, fromAddress, sendEmail, type EmailSendResult } from "@/vendor/email/resend";
|
|
23
|
+
|
|
24
|
+
/** A QR code is a few kB. Anything this size is not one, and is not attached. */
|
|
25
|
+
const MAX_QR_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
// `[\s\S]` rather than the `s` flag: the template's tsconfig targets an older
|
|
28
|
+
// ES level than dotAll, and a base64 payload can contain newlines.
|
|
29
|
+
const DATA_URI = /^data:([^;,]+)?(;base64)?,([\s\S]*)$/;
|
|
30
|
+
|
|
31
|
+
function toBase64(bytes: Uint8Array): string {
|
|
32
|
+
// Chunked so a large-ish buffer cannot blow the argument limit of
|
|
33
|
+
// String.fromCharCode. btoa exists in both Workers and Node 18+.
|
|
34
|
+
let binary = "";
|
|
35
|
+
const chunk = 0x8000;
|
|
36
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
37
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
38
|
+
}
|
|
39
|
+
return btoa(binary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch or decode the QR into base64 bytes. Null whenever that cannot be done
|
|
44
|
+
* — the email is still worth sending, because the manual credentials and the
|
|
45
|
+
* written steps below the QR are what most people use anyway.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveQrImage(qrCode: string | undefined): Promise<QrImage | null> {
|
|
48
|
+
if (!qrCode) return null;
|
|
49
|
+
|
|
50
|
+
const data = DATA_URI.exec(qrCode);
|
|
51
|
+
if (data) {
|
|
52
|
+
const [, contentType, isBase64, payload] = data;
|
|
53
|
+
if (!isBase64 || !payload) return null;
|
|
54
|
+
return { base64: payload.replace(/\s+/g, ""), contentType: contentType || "image/png" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!/^https:\/\//i.test(qrCode)) return null;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(qrCode, { cache: "no-store" });
|
|
61
|
+
if (!res.ok) return null;
|
|
62
|
+
const buffer = await res.arrayBuffer();
|
|
63
|
+
if (buffer.byteLength === 0 || buffer.byteLength > MAX_QR_BYTES) return null;
|
|
64
|
+
return {
|
|
65
|
+
base64: toBase64(new Uint8Array(buffer)),
|
|
66
|
+
contentType: res.headers.get("content-type")?.split(";")[0]?.trim() || "image/png",
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type QrDeliveryResult =
|
|
74
|
+
| { sent: true; id?: string; withQrImage: boolean }
|
|
75
|
+
| { sent: false; reason: "unconfigured" | "no-from-address" | "rejected" | "network-error"; detail?: string };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Send the delivery email for one ready order.
|
|
79
|
+
*
|
|
80
|
+
* Never throws and never reports success it did not get. A storefront with no
|
|
81
|
+
* Resend key returns `unconfigured`, which the caller shows as a plain
|
|
82
|
+
* sentence — the order is complete either way, and pretending an email went
|
|
83
|
+
* out is worse than saying none did.
|
|
84
|
+
*/
|
|
85
|
+
export async function deliverQrEmail(order: EsimOrder, to: string): Promise<QrDeliveryResult> {
|
|
86
|
+
if (!emailConfigured()) return { sent: false, reason: "unconfigured" };
|
|
87
|
+
if (!fromAddress()) return { sent: false, reason: "no-from-address" };
|
|
88
|
+
|
|
89
|
+
const qr = await resolveQrImage(order.qrCode);
|
|
90
|
+
const message = buildQrDeliveryEmail({
|
|
91
|
+
to,
|
|
92
|
+
order,
|
|
93
|
+
brand: {
|
|
94
|
+
name: brand.name,
|
|
95
|
+
supportEmail: brand.supportEmail,
|
|
96
|
+
supportUrl: brand.supportUrl,
|
|
97
|
+
appUrl: brand.appUrl,
|
|
98
|
+
colors: { accent: brand.colors.accent },
|
|
99
|
+
},
|
|
100
|
+
qr,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result: EmailSendResult = await sendEmail(message);
|
|
104
|
+
if (!result.ok) return { sent: false, reason: result.reason, detail: result.detail };
|
|
105
|
+
return { sent: true, id: result.id, withQrImage: qr !== null };
|
|
106
|
+
}
|
|
@@ -9,7 +9,7 @@ const isPublicRoute = createRouteMatcher([
|
|
|
9
9
|
"/sign-in(.*)",
|
|
10
10
|
"/sign-up(.*)",
|
|
11
11
|
"/api/health",
|
|
12
|
-
//
|
|
12
|
+
// Guest checkout API and post-payment success page are public
|
|
13
13
|
// (user has not yet created an account at this point in the purchase flow)
|
|
14
14
|
"/api/checkout/guest",
|
|
15
15
|
"/checkout/success(.*)",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* to a charge must read `source`, not just the array. `fetchTemplates()` keeps
|
|
13
13
|
* the plain-array shape for the pages that only render.
|
|
14
14
|
*/
|
|
15
|
-
import type { EsimOrder, SkuTemplate } from "./types";
|
|
15
|
+
import type { EsimOrder, EsimUsage, SkuTemplate, TopUpOption } from "./types";
|
|
16
16
|
|
|
17
17
|
const MOCK_TEMPLATES: SkuTemplate[] = [
|
|
18
18
|
{
|
|
@@ -190,3 +190,88 @@ export async function fetchOrder(orderId: string): Promise<OrderLookup> {
|
|
|
190
190
|
return { ok: false, reason: "network-error" };
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Call one order-scoped fulfilment route and classify the failure.
|
|
196
|
+
*
|
|
197
|
+
* Shared by usage, top-up options and cancel so that every one of them reports
|
|
198
|
+
* the same four reasons in the same words. A page that says "we can't reach
|
|
199
|
+
* fulfilment" for one action and nothing at all for another teaches the
|
|
200
|
+
* customer that the second action silently does nothing.
|
|
201
|
+
*/
|
|
202
|
+
async function orderScopedCall<T>(
|
|
203
|
+
orderId: string,
|
|
204
|
+
path: string,
|
|
205
|
+
init?: RequestInit,
|
|
206
|
+
): Promise<{ ok: true; data: T } | { ok: false; reason: OrderLookupError }> {
|
|
207
|
+
const base = apiBase();
|
|
208
|
+
if (!base) return { ok: false, reason: "unconfigured" };
|
|
209
|
+
if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" };
|
|
210
|
+
|
|
211
|
+
const headers: Record<string, string> = { Accept: "application/json", ...(init?.headers as Record<string, string>) };
|
|
212
|
+
const key = apiKey();
|
|
213
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}${path}`, {
|
|
217
|
+
...init,
|
|
218
|
+
headers,
|
|
219
|
+
cache: "no-store",
|
|
220
|
+
});
|
|
221
|
+
if (res.status === 404) return { ok: false, reason: "not-found" };
|
|
222
|
+
if (!res.ok) return { ok: false, reason: "upstream-error" };
|
|
223
|
+
return { ok: true, data: (await res.json()) as T };
|
|
224
|
+
} catch {
|
|
225
|
+
return { ok: false, reason: "network-error" };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* How much of the plan is gone.
|
|
231
|
+
*
|
|
232
|
+
* Contract: GET {origin}/api/orders/{orderId}/usage -> EsimUsage.
|
|
233
|
+
* Until an origin serves it the dashboard says the meter is unavailable. It
|
|
234
|
+
* never shows a zero, because "no data used" and "we could not ask" look
|
|
235
|
+
* identical on a meter and mean opposite things to someone about to travel.
|
|
236
|
+
*/
|
|
237
|
+
export async function fetchUsage(
|
|
238
|
+
orderId: string,
|
|
239
|
+
): Promise<{ ok: true; usage: EsimUsage } | { ok: false; reason: OrderLookupError }> {
|
|
240
|
+
const res = await orderScopedCall<EsimUsage>(orderId, "/usage");
|
|
241
|
+
if (!res.ok) return res;
|
|
242
|
+
if (typeof res.data?.usedBytes !== "number") return { ok: false, reason: "upstream-error" };
|
|
243
|
+
return { ok: true, usage: res.data };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* What this eSIM can be topped up with.
|
|
248
|
+
*
|
|
249
|
+
* Contract: GET {origin}/api/orders/{orderId}/top-up-options -> TopUpOption[].
|
|
250
|
+
* An empty array is a valid answer and means this plan cannot be topped up —
|
|
251
|
+
* distinct from a failure, and rendered as such.
|
|
252
|
+
*/
|
|
253
|
+
export async function fetchTopUpOptions(
|
|
254
|
+
orderId: string,
|
|
255
|
+
): Promise<{ ok: true; options: TopUpOption[] } | { ok: false; reason: OrderLookupError }> {
|
|
256
|
+
const res = await orderScopedCall<TopUpOption[]>(orderId, "/top-up-options");
|
|
257
|
+
if (!res.ok) return res;
|
|
258
|
+
if (!Array.isArray(res.data)) return { ok: false, reason: "upstream-error" };
|
|
259
|
+
return { ok: true, options: res.data };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Stop billing this eSIM.
|
|
264
|
+
*
|
|
265
|
+
* Contract: POST {origin}/api/orders/{orderId}/cancel -> { cancelledAt: string }.
|
|
266
|
+
* The operator decides what cancellation means for a plan mid-cycle; the
|
|
267
|
+
* storefront only reports back what it was told.
|
|
268
|
+
*/
|
|
269
|
+
export async function cancelOrder(
|
|
270
|
+
orderId: string,
|
|
271
|
+
): Promise<{ ok: true; cancelledAt?: string } | { ok: false; reason: OrderLookupError }> {
|
|
272
|
+
const res = await orderScopedCall<{ cancelledAt?: string }>(orderId, "/cancel", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) return res;
|
|
276
|
+
return { ok: true, cancelledAt: res.data?.cancelledAt };
|
|
277
|
+
}
|
|
@@ -55,4 +55,38 @@ export interface EsimOrder {
|
|
|
55
55
|
expiresAt?: string;
|
|
56
56
|
/** Operator-supplied, customer-safe explanation when status is "failed". */
|
|
57
57
|
failureMessage?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The plan is no longer billable. Set once a cancellation has been accepted,
|
|
60
|
+
* so the dashboard can stop offering to cancel it twice.
|
|
61
|
+
*/
|
|
62
|
+
cancelledAt?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What one eSIM has actually carried, as a customer meter shows it.
|
|
67
|
+
*
|
|
68
|
+
* `usedBytes` is the only field that must be present. An operator that cannot
|
|
69
|
+
* state an allowance (an unmetered plan, or a bundle whose ceiling lives
|
|
70
|
+
* elsewhere) omits `totalBytes`, and the meter renders as a figure rather than
|
|
71
|
+
* a bar. Inventing a denominator to keep a bar drawable would turn "we do not
|
|
72
|
+
* know" into a number the customer would plan a trip around.
|
|
73
|
+
*/
|
|
74
|
+
export interface EsimUsage {
|
|
75
|
+
iccid?: string;
|
|
76
|
+
usedBytes: number;
|
|
77
|
+
totalBytes?: number;
|
|
78
|
+
/** When the plan stops working, whatever is left on it. */
|
|
79
|
+
expiresAt?: string;
|
|
80
|
+
/** Operator's own timestamp for this reading. */
|
|
81
|
+
measuredAt?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One way to put more data on an eSIM that already exists. */
|
|
85
|
+
export interface TopUpOption {
|
|
86
|
+
id: TemplateId;
|
|
87
|
+
name: string;
|
|
88
|
+
dataGb: number;
|
|
89
|
+
validityDays: number;
|
|
90
|
+
price_cents: number;
|
|
91
|
+
currency: string;
|
|
58
92
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Your eSIM is ready" — the message a customer actually installs from.
|
|
3
|
+
*
|
|
4
|
+
* Written as a plain HTML string rather than React Email components, for the
|
|
5
|
+
* reason given in resend.ts: this file ships into somebody else's repository,
|
|
6
|
+
* and an operator re-wording their own delivery email should not first have to
|
|
7
|
+
* learn a component library. Inline styles and a single-column table are the
|
|
8
|
+
* only layout most mail clients render the same way twice.
|
|
9
|
+
*
|
|
10
|
+
* The layering is deliberate, worst case first:
|
|
11
|
+
*
|
|
12
|
+
* 1. A QR code, attached and referenced as `cid:` — never a `data:` URI,
|
|
13
|
+
* which Gmail strips.
|
|
14
|
+
* 2. A direct-install link, which needs no typing at all on iOS 17.4+.
|
|
15
|
+
* 3. The SM-DP+ address and the activation code as two separate values,
|
|
16
|
+
* because those are two separate fields on the phone. One opaque
|
|
17
|
+
* `LPA:1$…$…` blob is not something a person can act on.
|
|
18
|
+
* 4. The steps, in words, for iPhone and for Android.
|
|
19
|
+
*
|
|
20
|
+
* A QR code alone fails the commonest case there is: the customer is reading
|
|
21
|
+
* this on the phone the eSIM is for, and cannot photograph its own screen.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
ANDROID_INSTALL_STEPS,
|
|
25
|
+
IPHONE_INSTALL_STEPS,
|
|
26
|
+
directInstallUrl,
|
|
27
|
+
manualActivationCode,
|
|
28
|
+
} from "@/lib/esim-install";
|
|
29
|
+
import type { EmailAttachment, EmailMessage } from "./resend";
|
|
30
|
+
|
|
31
|
+
/** The QR image, already fetched and base64-encoded by the caller. */
|
|
32
|
+
export interface QrImage {
|
|
33
|
+
base64: string;
|
|
34
|
+
contentType: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QrDeliveryBrand {
|
|
38
|
+
name: string;
|
|
39
|
+
supportEmail?: string;
|
|
40
|
+
supportUrl?: string;
|
|
41
|
+
appUrl?: string;
|
|
42
|
+
colors?: { accent?: string; text?: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QrDeliveryOrder {
|
|
46
|
+
orderId: string;
|
|
47
|
+
planName?: string;
|
|
48
|
+
iccid?: string;
|
|
49
|
+
activationCode?: string;
|
|
50
|
+
smdpAddress?: string;
|
|
51
|
+
matchingId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QrDeliveryInput {
|
|
55
|
+
to: string;
|
|
56
|
+
order: QrDeliveryOrder;
|
|
57
|
+
brand: QrDeliveryBrand;
|
|
58
|
+
/** Absent when the operator's fulfilment produced no renderable QR. */
|
|
59
|
+
qr?: QrImage | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The content id the HTML points at. Any stable token works; this one is
|
|
64
|
+
* spelled out so the test can assert the HTML and the attachment agree.
|
|
65
|
+
*/
|
|
66
|
+
export const QR_CONTENT_ID = "esim-qr";
|
|
67
|
+
|
|
68
|
+
function escapeHtml(value: string): string {
|
|
69
|
+
return value
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/</g, "<")
|
|
72
|
+
.replace(/>/g, ">")
|
|
73
|
+
.replace(/"/g, """)
|
|
74
|
+
.replace(/'/g, "'");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function steps(title: string, items: readonly string[]): string {
|
|
78
|
+
return `
|
|
79
|
+
<p style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">${escapeHtml(
|
|
80
|
+
title,
|
|
81
|
+
)}</p>
|
|
82
|
+
<ol style="margin:0 0 20px;padding-left:20px;font-size:14px;line-height:1.6;color:#374151;">
|
|
83
|
+
${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}
|
|
84
|
+
</ol>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One labelled credential, in a font a person can read a hyphen in. */
|
|
88
|
+
function credential(label: string, value: string): string {
|
|
89
|
+
return `
|
|
90
|
+
<tr>
|
|
91
|
+
<td style="padding:0 0 14px;">
|
|
92
|
+
<p style="margin:0 0 2px;font-size:12px;color:#6b7280;">${escapeHtml(label)}</p>
|
|
93
|
+
<p style="margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:14px;word-break:break-all;color:#111827;">${escapeHtml(
|
|
94
|
+
value,
|
|
95
|
+
)}</p>
|
|
96
|
+
</td>
|
|
97
|
+
</tr>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the message. Pure: every input is an argument, so the shape of what
|
|
102
|
+
* goes out can be asserted without a network or an API key.
|
|
103
|
+
*/
|
|
104
|
+
export function buildQrDeliveryEmail(input: QrDeliveryInput): EmailMessage {
|
|
105
|
+
const { to, order, brand, qr } = input;
|
|
106
|
+
const accent = brand.colors?.accent ?? "#1a1a1a";
|
|
107
|
+
const lpa = manualActivationCode(order);
|
|
108
|
+
const attachments: EmailAttachment[] = [];
|
|
109
|
+
|
|
110
|
+
let qrBlock = "";
|
|
111
|
+
if (qr) {
|
|
112
|
+
attachments.push({
|
|
113
|
+
filename: "esim-qr.png",
|
|
114
|
+
content: qr.base64,
|
|
115
|
+
content_type: qr.contentType,
|
|
116
|
+
content_id: QR_CONTENT_ID,
|
|
117
|
+
});
|
|
118
|
+
qrBlock = `
|
|
119
|
+
<tr>
|
|
120
|
+
<td align="center" style="padding:8px 32px 24px;">
|
|
121
|
+
<img src="cid:${QR_CONTENT_ID}" width="200" height="200" alt="eSIM activation QR code" style="display:block;border:0;background:#ffffff;padding:8px;border-radius:12px;" />
|
|
122
|
+
<p style="margin:12px 0 0;font-size:13px;color:#6b7280;">Scan this from another device’s camera.</p>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const installButton = lpa
|
|
128
|
+
? `
|
|
129
|
+
<tr>
|
|
130
|
+
<td align="center" style="padding:0 32px 8px;">
|
|
131
|
+
<a href="${escapeHtml(directInstallUrl(lpa))}" style="display:inline-block;background:${escapeHtml(
|
|
132
|
+
accent,
|
|
133
|
+
)};color:#ffffff;font-size:16px;font-weight:600;padding:14px 32px;border-radius:999px;text-decoration:none;">Install on this iPhone</a>
|
|
134
|
+
<p style="margin:10px 0 0;font-size:12px;color:#6b7280;">One tap, no typing. iPhone on iOS 17.4 or later. On Android, use the steps below.</p>
|
|
135
|
+
</td>
|
|
136
|
+
</tr>`
|
|
137
|
+
: "";
|
|
138
|
+
|
|
139
|
+
const credentials = [
|
|
140
|
+
order.smdpAddress ? credential("SM-DP+ address", order.smdpAddress) : "",
|
|
141
|
+
order.matchingId ? credential("Activation code", order.matchingId) : "",
|
|
142
|
+
order.iccid ? credential("ICCID", order.iccid) : "",
|
|
143
|
+
].join("");
|
|
144
|
+
|
|
145
|
+
const manualBlock = credentials
|
|
146
|
+
? `
|
|
147
|
+
<tr>
|
|
148
|
+
<td style="padding:24px 32px 0;">
|
|
149
|
+
<div style="border:1px solid #e5e7eb;border-radius:16px;padding:20px;">
|
|
150
|
+
<p style="margin:0 0 14px;font-size:14px;font-weight:600;color:#111827;">Install manually</p>
|
|
151
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${credentials}</table>
|
|
152
|
+
<div style="border-top:1px solid #e5e7eb;padding-top:18px;margin-top:4px;">
|
|
153
|
+
${steps("On iPhone", IPHONE_INSTALL_STEPS)}
|
|
154
|
+
${steps("On Android", ANDROID_INSTALL_STEPS)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</td>
|
|
158
|
+
</tr>`
|
|
159
|
+
: "";
|
|
160
|
+
|
|
161
|
+
const activateUrl = brand.appUrl
|
|
162
|
+
? `${brand.appUrl.replace(/\/+$/, "")}/activate/${encodeURIComponent(order.orderId)}`
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
const supportLine = brand.supportEmail
|
|
166
|
+
? `Questions? <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:${escapeHtml(
|
|
167
|
+
accent,
|
|
168
|
+
)};">${escapeHtml(brand.supportEmail)}</a>`
|
|
169
|
+
: brand.supportUrl
|
|
170
|
+
? `Questions? <a href="${escapeHtml(brand.supportUrl)}" style="color:${escapeHtml(accent)};">Contact support</a>`
|
|
171
|
+
: "";
|
|
172
|
+
|
|
173
|
+
const html = `<!DOCTYPE html>
|
|
174
|
+
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><title>${escapeHtml(
|
|
175
|
+
brand.name,
|
|
176
|
+
)}</title></head>
|
|
177
|
+
<body style="margin:0;padding:0;background:#f6f6f4;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;">
|
|
178
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f6f6f4;">
|
|
179
|
+
<tr><td align="center" style="padding:24px 12px;">
|
|
180
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background:#ffffff;border-radius:20px;overflow:hidden;">
|
|
181
|
+
<tr>
|
|
182
|
+
<td style="background:${escapeHtml(accent)};padding:20px 32px;">
|
|
183
|
+
<p style="margin:0;color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-0.4px;">${escapeHtml(
|
|
184
|
+
brand.name,
|
|
185
|
+
)}</p>
|
|
186
|
+
</td>
|
|
187
|
+
</tr>
|
|
188
|
+
<tr>
|
|
189
|
+
<td style="padding:28px 32px 4px;">
|
|
190
|
+
<p style="margin:0 0 8px;font-size:24px;font-weight:700;color:#111827;">Your eSIM is ready.</p>
|
|
191
|
+
<p style="margin:0;font-size:15px;line-height:1.55;color:#4b5563;">Install it now and it stays dormant until you arrive. ${
|
|
192
|
+
order.planName ? `Your plan: <strong>${escapeHtml(order.planName)}</strong>.` : ""
|
|
193
|
+
}</p>
|
|
194
|
+
</td>
|
|
195
|
+
</tr>
|
|
196
|
+
${installButton}
|
|
197
|
+
${qrBlock}
|
|
198
|
+
${manualBlock}
|
|
199
|
+
<tr>
|
|
200
|
+
<td style="padding:24px 32px 28px;">
|
|
201
|
+
${
|
|
202
|
+
activateUrl
|
|
203
|
+
? `<p style="margin:0 0 10px;font-size:13px;color:#6b7280;">All of this is also on your <a href="${escapeHtml(
|
|
204
|
+
activateUrl,
|
|
205
|
+
)}" style="color:${escapeHtml(accent)};">activation page</a>.</p>`
|
|
206
|
+
: ""
|
|
207
|
+
}
|
|
208
|
+
<p style="margin:0 0 6px;font-size:13px;color:#6b7280;">${supportLine}</p>
|
|
209
|
+
<p style="margin:0;font-size:12px;color:#9ca3af;">Order ${escapeHtml(
|
|
210
|
+
order.orderId,
|
|
211
|
+
)} · You are receiving this because you bought a plan from ${escapeHtml(
|
|
212
|
+
brand.name,
|
|
213
|
+
)}.</p>
|
|
214
|
+
</td>
|
|
215
|
+
</tr>
|
|
216
|
+
</table>
|
|
217
|
+
</td></tr>
|
|
218
|
+
</table>
|
|
219
|
+
</body></html>`;
|
|
220
|
+
|
|
221
|
+
// null drops a line that does not apply. "" is a blank line on purpose:
|
|
222
|
+
// filtering every empty string turns the five install routes into one paragraph.
|
|
223
|
+
const text = [
|
|
224
|
+
"Your eSIM is ready.",
|
|
225
|
+
order.planName ? `Plan: ${order.planName}` : null,
|
|
226
|
+
"",
|
|
227
|
+
lpa ? `Install on iPhone (iOS 17.4+), one tap: ${directInstallUrl(lpa)}` : null,
|
|
228
|
+
"",
|
|
229
|
+
"Install manually:",
|
|
230
|
+
order.smdpAddress ? ` SM-DP+ address: ${order.smdpAddress}` : null,
|
|
231
|
+
order.matchingId ? ` Activation code: ${order.matchingId}` : null,
|
|
232
|
+
order.iccid ? ` ICCID: ${order.iccid}` : null,
|
|
233
|
+
"",
|
|
234
|
+
"On iPhone:",
|
|
235
|
+
...IPHONE_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
|
|
236
|
+
"",
|
|
237
|
+
"On Android:",
|
|
238
|
+
...ANDROID_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
|
|
239
|
+
"",
|
|
240
|
+
activateUrl ? `Activation page: ${activateUrl}` : null,
|
|
241
|
+
brand.supportEmail ? `Questions? ${brand.supportEmail}` : null,
|
|
242
|
+
`Order ${order.orderId}`,
|
|
243
|
+
]
|
|
244
|
+
.filter((line) => line !== null)
|
|
245
|
+
.join("\n");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
to,
|
|
249
|
+
subject: `Your ${brand.name} eSIM is ready`,
|
|
250
|
+
html,
|
|
251
|
+
text,
|
|
252
|
+
attachments,
|
|
253
|
+
};
|
|
254
|
+
}
|