@carrierllc/mcp 0.11.17 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/{chunk-EHGZRVSO.js → chunk-ZUUC3J4J.js} +26 -3
  2. package/dist/chunk-ZUUC3J4J.js.map +1 -0
  3. package/dist/cli.js +397 -16
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +1 -1
  7. package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
  8. package/plugin/carrier/skills/storefront/SKILL.md +2 -2
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +205 -17
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
  12. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
  13. package/templates/storefront/src/app/api/orders/[orderId]/email/route.ts +73 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
  15. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  16. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  17. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  18. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  19. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  20. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  21. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  22. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  24. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  25. package/templates/storefront/src/brand.config.ts +19 -0
  26. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  27. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  28. package/templates/storefront/src/lib/checkout-order-claim.ts +113 -8
  29. package/templates/storefront/src/lib/esim-install.ts +74 -0
  30. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  31. package/templates/storefront/src/lib/qr-delivery-email.ts +106 -0
  32. package/templates/storefront/src/middleware.ts +1 -1
  33. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  34. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  35. package/templates/storefront/src/vendor/email/qr-delivery.ts +254 -0
  36. package/templates/storefront/src/vendor/email/resend.ts +140 -0
  37. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  38. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  39. package/dist/chunk-EHGZRVSO.js.map +0 -1
@@ -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: `Unlimited data in 200+ countries. Instant QR activation. WhatsApp support.`,
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,