@carrierllc/mcp 0.11.17 → 0.12.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 (34) hide show
  1. package/dist/{chunk-EHGZRVSO.js → chunk-J52VP7E7.js} +14 -2
  2. package/dist/chunk-J52VP7E7.js.map +1 -0
  3. package/dist/cli.js +13 -5
  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 +145 -14
  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]/top-up-options/route.ts +28 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  15. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  16. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  17. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  18. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  19. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  20. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  21. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  22. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  24. package/templates/storefront/src/brand.config.ts +19 -0
  25. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  26. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  27. package/templates/storefront/src/lib/checkout-order-claim.ts +68 -4
  28. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  29. package/templates/storefront/src/middleware.ts +1 -1
  30. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  31. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  32. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  33. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  34. package/dist/chunk-EHGZRVSO.js.map +0 -1
@@ -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
  }
@@ -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,