@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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional email over the Resend HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately no SDK. This template is copied into other people's
|
|
5
|
+
* repositories, so every dependency added here is one the operator inherits,
|
|
6
|
+
* has to audit, and has to keep patched. Sending one message is a single POST
|
|
7
|
+
* with a JSON body; `resend` and `@react-email/components` would buy us
|
|
8
|
+
* nothing that `fetch` does not already do, and `@react-email/components`
|
|
9
|
+
* would additionally drag a server-side React render into a Cloudflare Worker
|
|
10
|
+
* request path. The same reasoning put Stripe.js on a CDN rather than in
|
|
11
|
+
* package.json.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that choice is honest: no typed client, and the field names
|
|
14
|
+
* below are the raw API's snake_case (`content_id`, not the SDK's
|
|
15
|
+
* `contentId`). They are pinned by tests.
|
|
16
|
+
*/
|
|
17
|
+
import brand from "@/brand.config";
|
|
18
|
+
|
|
19
|
+
const RESEND_API = "https://api.resend.com";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resend rejects any request without a User-Agent with a 403 that looks
|
|
23
|
+
* exactly like a bad key. Workers do not set one, so we always do.
|
|
24
|
+
*/
|
|
25
|
+
const USER_AGENT = "carrier-storefront/1.0";
|
|
26
|
+
|
|
27
|
+
/** One file travelling with the message. Inline when `content_id` is set. */
|
|
28
|
+
export interface EmailAttachment {
|
|
29
|
+
filename: string;
|
|
30
|
+
/** Base64, no data: prefix. */
|
|
31
|
+
content: string;
|
|
32
|
+
content_type?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Makes this attachment addressable from the HTML as `cid:<content_id>`.
|
|
35
|
+
*
|
|
36
|
+
* This is the whole reason the QR is an attachment rather than a `data:`
|
|
37
|
+
* URI in an `<img src>`. Gmail strips `data:` image sources, so a `data:`
|
|
38
|
+
* QR renders as a broken image for a large share of buyers — and a broken
|
|
39
|
+
* QR in a "your eSIM is ready" email is a support ticket, every time.
|
|
40
|
+
*/
|
|
41
|
+
content_id?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EmailMessage {
|
|
45
|
+
to: string;
|
|
46
|
+
subject: string;
|
|
47
|
+
html: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
attachments?: EmailAttachment[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why mail cannot be sent, when it cannot.
|
|
54
|
+
*
|
|
55
|
+
* "unconfigured" is a normal state, not a fault: a storefront with no Resend
|
|
56
|
+
* key still sells eSIMs, and every caller must treat this as a thing to
|
|
57
|
+
* mention rather than a thing to fail on.
|
|
58
|
+
*/
|
|
59
|
+
export type EmailUnavailable = "unconfigured" | "no-from-address";
|
|
60
|
+
|
|
61
|
+
export type EmailSendResult =
|
|
62
|
+
| { ok: true; id?: string }
|
|
63
|
+
| { ok: false; reason: EmailUnavailable | "rejected" | "network-error"; detail?: string };
|
|
64
|
+
|
|
65
|
+
/** Server-side only. Never reached from a client bundle, so never inlined. */
|
|
66
|
+
function apiKey(): string {
|
|
67
|
+
return process.env.RESEND_API_KEY?.trim() ?? "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emailConfigured(): boolean {
|
|
71
|
+
return apiKey().length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The address this storefront sends from, composed from the operator's own
|
|
76
|
+
* mailbox and their own domain.
|
|
77
|
+
*
|
|
78
|
+
* It is composed rather than stored whole on purpose. A literal from-address
|
|
79
|
+
* in a white-label template drifts onto a domain the operator does not own the
|
|
80
|
+
* first time somebody copies the file, and mail from a domain you cannot add
|
|
81
|
+
* SPF and DKIM records to does not arrive. There is no default: if the brand
|
|
82
|
+
* has no mailbox or no domain, this returns null and the caller says email is
|
|
83
|
+
* not configured rather than sending from somebody else's domain.
|
|
84
|
+
*/
|
|
85
|
+
export function fromAddress(
|
|
86
|
+
config: { emailFromName?: string; emailFromMailbox?: string; domain?: string } = brand,
|
|
87
|
+
): string | null {
|
|
88
|
+
const mailbox = config.emailFromMailbox?.trim();
|
|
89
|
+
const domain = config.domain?.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
90
|
+
if (!mailbox || !domain) return null;
|
|
91
|
+
|
|
92
|
+
const address = `${mailbox}@${domain}`;
|
|
93
|
+
const name = config.emailFromName?.trim();
|
|
94
|
+
return name ? `${name} <${address}>` : address;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send one message. Never throws: a storefront that cannot send an email must
|
|
99
|
+
* still be able to finish taking an order.
|
|
100
|
+
*/
|
|
101
|
+
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
|
|
102
|
+
const key = apiKey();
|
|
103
|
+
if (!key) return { ok: false, reason: "unconfigured" };
|
|
104
|
+
|
|
105
|
+
const from = fromAddress();
|
|
106
|
+
if (!from) return { ok: false, reason: "no-from-address" };
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${RESEND_API}/emails`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${key}`,
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"User-Agent": USER_AGENT,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
from,
|
|
118
|
+
to: [message.to],
|
|
119
|
+
subject: message.subject,
|
|
120
|
+
html: message.html,
|
|
121
|
+
...(message.text ? { text: message.text } : {}),
|
|
122
|
+
...(message.attachments?.length ? { attachments: message.attachments } : {}),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
128
|
+
return { ok: false, reason: "rejected", detail: `HTTP ${res.status} ${detail}`.trim() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = (await res.json().catch(() => ({}))) as { id?: string };
|
|
132
|
+
return { ok: true, id: body.id };
|
|
133
|
+
} catch (e: unknown) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: "network-error",
|
|
137
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -19,6 +19,51 @@ export function formatPrice(cents: number, currency = "EUR", locale = "en"): str
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A plan's data allowance as a shopper reads it.
|
|
24
|
+
*
|
|
25
|
+
* Zero is not "0 GB" — an unmetered plan is the thing the customer is being
|
|
26
|
+
* sold, and printing a zero next to a price reads as a broken row.
|
|
27
|
+
*/
|
|
28
|
+
export function formatDataAllowance(dataGb: number): string {
|
|
29
|
+
if (!Number.isFinite(dataGb) || dataGb <= 0) return "Unlimited data";
|
|
30
|
+
if (dataGb < 1) return `${Math.round(dataGb * 1024)} MB data`;
|
|
31
|
+
return `${Number.isInteger(dataGb) ? dataGb : dataGb.toFixed(1)} GB data`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A plan's validity window, singular-aware. */
|
|
35
|
+
export function formatValidity(validityDays: number): string {
|
|
36
|
+
if (!Number.isFinite(validityDays) || validityDays <= 0) return "No expiry";
|
|
37
|
+
if (validityDays === 1) return "1 day";
|
|
38
|
+
if (validityDays % 7 === 0 && validityDays >= 7) {
|
|
39
|
+
const weeks = validityDays / 7;
|
|
40
|
+
return weeks === 1 ? "1 week" : `${weeks} weeks`;
|
|
41
|
+
}
|
|
42
|
+
return `${validityDays} days`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bytes as a usage meter shows them.
|
|
47
|
+
*
|
|
48
|
+
* Decimal units (1 GB = 1000 MB), because that is the unit a data bundle is
|
|
49
|
+
* sold in — showing 0.93 GB left of a 1 GB plan the moment it is bought is a
|
|
50
|
+
* support ticket, not a rounding preference.
|
|
51
|
+
*/
|
|
52
|
+
export function formatBytes(bytes: number): string {
|
|
53
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 MB";
|
|
54
|
+
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(bytes >= 10_000_000_000 ? 0 : 1)} GB`;
|
|
55
|
+
if (bytes >= 1_000_000) return `${Math.round(bytes / 1_000_000)} MB`;
|
|
56
|
+
return `${Math.max(1, Math.round(bytes / 1_000))} KB`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Days left until an ISO timestamp, floored at zero. Null when unparseable. */
|
|
60
|
+
export function daysUntil(iso: string | undefined, now: Date = new Date()): number | null {
|
|
61
|
+
if (!iso) return null;
|
|
62
|
+
const then = Date.parse(iso);
|
|
63
|
+
if (Number.isNaN(then)) return null;
|
|
64
|
+
return Math.max(0, Math.ceil((then - now.getTime()) / 86_400_000));
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export type DisplayCurrency = "EUR" | "USD" | "GBP";
|
|
23
68
|
|
|
24
69
|
export const SUPPORTED_CURRENCIES: DisplayCurrency[] = ["EUR", "USD", "GBP"];
|
|
@@ -7,8 +7,15 @@ export const BRAND = {
|
|
|
7
7
|
name: brand.name,
|
|
8
8
|
legalName: brand.legalName,
|
|
9
9
|
tagline: brand.tagline,
|
|
10
|
-
description:
|
|
10
|
+
description: brand.description,
|
|
11
11
|
domain: brand.domain,
|
|
12
|
+
appUrl: brand.appUrl,
|
|
13
|
+
email: {
|
|
14
|
+
fromName: brand.emailFromName,
|
|
15
|
+
// Composed, not configured. Keeping the mailbox and the domain as one
|
|
16
|
+
// field is how a storefront ends up sending from a domain it does not own.
|
|
17
|
+
fromAddress: `${brand.emailFromMailbox}@${brand.domain}`,
|
|
18
|
+
},
|
|
12
19
|
support: {
|
|
13
20
|
email: brand.supportEmail,
|
|
14
21
|
whatsapp: brand.supportWhatsapp,
|