@carrierllc/mcp 0.13.22 → 0.13.25

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 (35) hide show
  1. package/dist/{chunk-2ZWA4XML.js → chunk-KFIIIQ4J.js} +9 -6
  2. package/dist/{chunk-2ZWA4XML.js.map → chunk-KFIIIQ4J.js.map} +1 -1
  3. package/dist/cli.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +1 -1
  6. package/templates/storefront/eslint.config.mjs +8 -1
  7. package/templates/storefront/package-lock.json +492 -654
  8. package/templates/storefront/package.json +19 -11
  9. package/templates/storefront/pnpm-lock.yaml +1391 -1592
  10. package/templates/storefront/pnpm-workspace.yaml +7 -0
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +203 -114
  12. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +11 -6
  13. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +7 -4
  14. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +7 -4
  15. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +22 -17
  16. package/templates/storefront/src/app/contact/page.tsx +3 -3
  17. package/templates/storefront/src/app/dashboard/EsimControls.tsx +4 -4
  18. package/templates/storefront/src/app/help/page.tsx +1 -1
  19. package/templates/storefront/src/app/shop/ShopClient.tsx +1 -1
  20. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +3 -2
  21. package/templates/storefront/src/app/sitemap.ts +1 -0
  22. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +4 -2
  23. package/templates/storefront/src/components/faq/FaqSection.tsx +1 -1
  24. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +2 -2
  25. package/templates/storefront/src/components/landing/FinalCTA.tsx +1 -1
  26. package/templates/storefront/src/components/landing/HeroSection.tsx +6 -2
  27. package/templates/storefront/src/components/nav/StorefrontNav.tsx +2 -1
  28. package/templates/storefront/src/components/theme/theme-provider.tsx +28 -17
  29. package/templates/storefront/src/lib/checkout-order-claim.ts +5 -14
  30. package/templates/storefront/src/lib/order-route-guard.ts +34 -0
  31. package/templates/storefront/src/middleware.ts +5 -0
  32. package/templates/storefront/src/vendor/carrier/client.ts +16 -2
  33. package/templates/storefront/src/vendor/carrier/types.ts +4 -0
  34. package/templates/storefront/src/vendor/geo/index.ts +5 -5
  35. package/templates/storefront/src/vendor/ui/brand.ts +1 -1
@@ -9,6 +9,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
9
9
  { url: `${base}/help`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
10
10
  { url: `${base}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.4 },
11
11
  { url: `${base}/legal/privacy`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.2 },
12
+ { url: `${base}/legal/acceptable-use`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.2 },
12
13
  { url: `${base}/legal/terms`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.2 },
13
14
  ];
14
15
  }
@@ -125,9 +125,11 @@ export function EmbeddedStripeCheckout({ clientSecret, onComplete, onUnavailable
125
125
  // passes inline closures does not re-run this effect and open a second
126
126
  // Checkout instance against one session.
127
127
  const onCompleteRef = useRef(onComplete);
128
- onCompleteRef.current = onComplete;
129
128
  const onUnavailableRef = useRef(onUnavailable);
130
- onUnavailableRef.current = onUnavailable;
129
+ useEffect(() => {
130
+ onCompleteRef.current = onComplete;
131
+ onUnavailableRef.current = onUnavailable;
132
+ }, [onComplete, onUnavailable]);
131
133
 
132
134
  useEffect(() => {
133
135
  if (!canEmbedStripeCheckout || !clientSecret) return;
@@ -69,7 +69,7 @@ export function FaqSection() {
69
69
  <FaqItem key={faq.q} q={faq.q} a={faq.a} />
70
70
  ))}
71
71
  <p className="mt-8 text-center text-sm text-sand-500">
72
- Still stuck? WhatsApp {brand.name} — real humans, 24/7.
72
+ Still stuck? WhatsApp {brand.name}.
73
73
  </p>
74
74
  </div>
75
75
  </section>
@@ -31,7 +31,7 @@ export function StorefrontFooter() {
31
31
  rel="noopener noreferrer"
32
32
  className="inline-flex items-center gap-2 rounded-full bg-leaf-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-leaf-600"
33
33
  >
34
- WhatsApp · 24/7
34
+ WhatsApp
35
35
  </Link>
36
36
  <Link
37
37
  href={buildSupportLink("sms")}
@@ -68,7 +68,7 @@ export function StorefrontFooter() {
68
68
  <p className="text-xs text-sand-600">
69
69
  © {new Date().getFullYear()} {BRAND.legalName}. All rights reserved.
70
70
  </p>
71
- <p className="text-xs text-sand-600">Real humans. 24/7.</p>
71
+ <p className="text-xs text-sand-600">{BRAND.support.hoursLocal}</p>
72
72
  </div>
73
73
  </div>
74
74
  </footer>
@@ -14,7 +14,7 @@ export function FinalCTA() {
14
14
  Travel light. Land connected.
15
15
  </h2>
16
16
  <p className="mx-auto mt-3 max-w-lg text-sm text-white/85">
17
- {brand.tagline} Real humans on WhatsApp, 24/7.
17
+ {brand.tagline}
18
18
  </p>
19
19
  <Link
20
20
  href="/shop"
@@ -1,10 +1,11 @@
1
+ import Image from "next/image";
1
2
  import Link from "next/link";
2
3
  import brand from "@/brand.config";
3
4
 
4
5
  const STATS = [
5
6
  { value: "200+", label: "countries" },
6
7
  { value: "<30s", label: "to activate" },
7
- { value: "24/7", label: "human support" },
8
+ { value: "1 business day", label: "reply target" },
8
9
  ];
9
10
 
10
11
  export function HeroSection() {
@@ -67,9 +68,12 @@ export function HeroSection() {
67
68
  key={item.label}
68
69
  className="relative overflow-hidden rounded-2xl border border-sand-200 bg-sand-100 shadow-sm dark:border-sand-800"
69
70
  >
70
- <img
71
+ <Image
71
72
  src={item.src}
72
73
  alt={item.label}
74
+ width={640}
75
+ height={480}
76
+ unoptimized
73
77
  className="aspect-[4/3] w-full object-cover"
74
78
  />
75
79
  <figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-sand-950/70 to-transparent px-3 py-2 text-xs font-medium text-white">
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import Image from "next/image";
4
5
  import Link from "next/link";
5
6
  import { usePathname } from "next/navigation";
6
7
  import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
@@ -22,7 +23,7 @@ export function StorefrontNav() {
22
23
  <header className="fixed inset-x-0 top-0 z-50 border-b border-sand-200/80 bg-sand-50/80 backdrop-blur-md dark:border-sand-800 dark:bg-sand-950/80">
23
24
  <div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
24
25
  <Link href="/" className="flex items-center gap-2 text-base font-semibold tracking-tight text-sand-900 dark:text-sand-50">
25
- <img src="/brand/logo.svg" alt="" width={32} height={32} className="size-8 rounded-lg" />
26
+ <Image src="/brand/logo.svg" alt="" width={32} height={32} unoptimized className="size-8 rounded-lg" />
26
27
  {brand.name}
27
28
  </Link>
28
29
 
@@ -29,30 +29,41 @@ function applyToDocument(theme: Theme) {
29
29
  root.style.colorScheme = theme;
30
30
  }
31
31
 
32
+ function subscribePreference(onChange: () => void) {
33
+ window.addEventListener("storage", onChange);
34
+ return () => window.removeEventListener("storage", onChange);
35
+ }
36
+
37
+ function readPreference(): ThemePreference {
38
+ try {
39
+ const stored = window.localStorage.getItem(STORAGE_KEY);
40
+ if (stored === "light" || stored === "dark" || stored === "system") return stored;
41
+ } catch {
42
+ /* private mode */
43
+ }
44
+ return "light";
45
+ }
46
+
47
+ function serverPreference(): ThemePreference {
48
+ return "light";
49
+ }
50
+
32
51
  export function ThemeProvider({ children }: { children: React.ReactNode }) {
33
- const [preference, setPreferenceState] = React.useState<ThemePreference>("light");
34
- const [theme, setTheme] = React.useState<Theme>("light");
52
+ const storedPreference = React.useSyncExternalStore(
53
+ subscribePreference, readPreference, serverPreference,
54
+ );
55
+ // Keep user changes usable even when private mode prevents persistence.
56
+ const [override, setPreferenceState] = React.useState<ThemePreference | null>(null);
57
+ const preference = override ?? storedPreference;
58
+ const theme = resolve(preference);
35
59
 
36
60
  React.useEffect(() => {
37
- try {
38
- const stored = window.localStorage.getItem(STORAGE_KEY);
39
- if (stored === "light" || stored === "dark" || stored === "system") {
40
- setPreferenceState(stored);
41
- const next = resolve(stored);
42
- setTheme(next);
43
- applyToDocument(next);
44
- return;
45
- }
46
- } catch {
47
- /* private mode */
48
- }
49
- applyToDocument("light");
50
- }, []);
61
+ applyToDocument(theme);
62
+ }, [theme]);
51
63
 
52
64
  const setPreference = React.useCallback((pref: ThemePreference) => {
53
65
  setPreferenceState(pref);
54
66
  const next = resolve(pref);
55
- setTheme(next);
56
67
  applyToDocument(next);
57
68
  try {
58
69
  window.localStorage.setItem(STORAGE_KEY, pref);
@@ -105,18 +105,16 @@ function sessionPurchaserEmail(session: StripeSession): string | null {
105
105
  /**
106
106
  * Does this Clerk user own the address that paid for the session?
107
107
  *
108
- * `requireVerified` is the difference between two very different questions.
109
- * Claiming an unclaimed order only needs the addresses on the account, because
110
- * nobody loses anything. Taking an order off another account needs a verified
111
- * address, because an unverified one is a string the claimant typed.
108
+ * Every live claim needs a verified paying address, including the first claim.
109
+ * An unverified address is just a string the claimant typed; knowing a session
110
+ * id never grants ownership.
112
111
  */
113
112
  async function userMatchesSessionPurchaser(
114
113
  session: StripeSession,
115
114
  userId: string,
116
- opts: { requireVerified?: boolean } = {},
117
115
  ): Promise<boolean> {
118
116
  const purchaserEmail = sessionPurchaserEmail(session);
119
- if (!purchaserEmail) return !opts.requireVerified;
117
+ if (!purchaserEmail) return false;
120
118
 
121
119
  const client = await clerkClient();
122
120
  const user = await client.users.getUser(userId);
@@ -124,7 +122,7 @@ async function userMatchesSessionPurchaser(
124
122
  return user.emailAddresses.some(
125
123
  (address) =>
126
124
  address.emailAddress.toLowerCase() === normalizedPurchaserEmail &&
127
- (!opts.requireVerified || address.verification?.status === "verified"),
125
+ address.verification?.status === "verified",
128
126
  );
129
127
  }
130
128
 
@@ -191,13 +189,6 @@ export async function claimCheckoutOrder(
191
189
  * Without that, anyone who learned a session id could take someone
192
190
  * else's order by typing their email into a new account.
193
191
  */
194
- const ownsPayingAddress = await userMatchesSessionPurchaser(session, userId, {
195
- requireVerified: true,
196
- });
197
- if (!ownsPayingAddress) {
198
- return { ok: false, status: 403, error: "This order is linked to another account" };
199
- }
200
-
201
192
  const moved = await setStripeClaimedBy(sessionId, userId);
202
193
  if (!moved) {
203
194
  return { ok: false, status: 502, error: "Failed to claim order" };
@@ -45,6 +45,40 @@ export async function guardOrderRoute(
45
45
  return { ok: true, userId, orderId };
46
46
  }
47
47
 
48
+ /**
49
+ * Server-side fulfilment call for the guarded proxy actions. The tenant comes
50
+ * only from deployment configuration, never from the caller's query/body.
51
+ * Keep the unscoped behavior for standalone shops; hosted orders require the
52
+ * slug upstream, which refuses a missing or mismatched tenant with 404.
53
+ */
54
+ export async function orderActionCall<T>(
55
+ orderId: string,
56
+ action: "usage" | "top-up-options" | "cancel",
57
+ ): Promise<{ ok: true; data: T } | { ok: false; reason: OrderLookupError }> {
58
+ const base = (process.env.NEXT_PUBLIC_CARRIER_API_URL ?? "").replace(/\/+$/, "");
59
+ if (!base) return { ok: false, reason: "unconfigured" };
60
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" };
61
+
62
+ const headers: Record<string, string> = { Accept: "application/json" };
63
+ const key = process.env.CARRIER_API_KEY;
64
+ if (key) headers.Authorization = `Bearer ${key}`;
65
+ try {
66
+ const url = new URL(`${base}/api/orders/${encodeURIComponent(orderId)}/${action}`);
67
+ const slug = process.env.NEXT_PUBLIC_STOREFRONT_SLUG;
68
+ if (slug) url.searchParams.set("storefront", slug);
69
+ const response = await fetch(url.toString(), {
70
+ method: action === "cancel" ? "POST" : "GET",
71
+ headers,
72
+ cache: "no-store",
73
+ });
74
+ if (response.status === 404) return { ok: false, reason: "not-found" };
75
+ if (!response.ok) return { ok: false, reason: "upstream-error" };
76
+ return { ok: true, data: (await response.json()) as T };
77
+ } catch {
78
+ return { ok: false, reason: "network-error" };
79
+ }
80
+ }
81
+
48
82
  /**
49
83
  * One fulfilment failure, as customer copy plus a status.
50
84
  *
@@ -1,3 +1,8 @@
1
+ // Keep the middleware.ts convention (not Next 16's proxy.ts). proxy.ts always
2
+ // runs on the Node.js runtime, and @opennextjs/cloudflare rejects Node.js
3
+ // middleware ("Node.js middleware is not currently supported"), which broke
4
+ // the template's cf:build in the npm Release job. middleware.ts still runs on
5
+ // the Edge runtime that the Cloudflare/OpenNext build supports.
1
6
  import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2
7
 
3
8
  const isPublicRoute = createRouteMatcher([
@@ -102,6 +102,14 @@ function apiKey(): string {
102
102
  return process.env.CARRIER_API_KEY ?? "";
103
103
  }
104
104
 
105
+ /** Order tenant scope comes only from deployment config, never buyer input. */
106
+ function orderPath(orderId: string, action = ""): string | null {
107
+ const path = `/api/orders/${encodeURIComponent(orderId)}${action}`;
108
+ const slug = process.env.NEXT_PUBLIC_STOREFRONT_SLUG;
109
+ if (slug && !/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(slug)) return null;
110
+ return slug ? `${path}?storefront=${encodeURIComponent(slug)}` : path;
111
+ }
112
+
105
113
  function sampleCatalog(reason: CatalogDegradedReason): TemplateCatalog {
106
114
  return { templates: MOCK_TEMPLATES, source: "sample", reason };
107
115
  }
@@ -190,12 +198,15 @@ export async function fetchOrder(orderId: string): Promise<OrderLookup> {
190
198
  if (!base) return { ok: false, reason: "unconfigured" };
191
199
  if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" };
192
200
 
201
+ const path = orderPath(orderId);
202
+ if (path === null) return { ok: false, reason: "unconfigured" };
203
+
193
204
  const headers: Record<string, string> = { Accept: "application/json" };
194
205
  const key = apiKey();
195
206
  if (key) headers.Authorization = `Bearer ${key}`;
196
207
 
197
208
  try {
198
- const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}`, {
209
+ const res = await fetch(`${base}${path}`, {
199
210
  headers,
200
211
  cache: "no-store",
201
212
  });
@@ -228,12 +239,15 @@ async function orderScopedCall<T>(
228
239
  if (!base) return { ok: false, reason: "unconfigured" };
229
240
  if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" };
230
241
 
242
+ const scopedPath = orderPath(orderId, path);
243
+ if (scopedPath === null) return { ok: false, reason: "unconfigured" };
244
+
231
245
  const headers: Record<string, string> = { Accept: "application/json", ...(init?.headers as Record<string, string>) };
232
246
  const key = apiKey();
233
247
  if (key) headers.Authorization = `Bearer ${key}`;
234
248
 
235
249
  try {
236
- const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}${path}`, {
250
+ const res = await fetch(`${base}${scopedPath}`, {
237
251
  ...init,
238
252
  headers,
239
253
  cache: "no-store",
@@ -6,6 +6,8 @@ export interface SkuTemplate {
6
6
  name: string;
7
7
  description: string;
8
8
  dataGb: number;
9
+ /** Only an explicit true value proves an unlimited allowance. */
10
+ unlimited?: boolean;
9
11
  validityDays: number;
10
12
  price_cents: number;
11
13
  currency: string;
@@ -86,6 +88,8 @@ export interface TopUpOption {
86
88
  id: TemplateId;
87
89
  name: string;
88
90
  dataGb: number;
91
+ /** Only an explicit true value proves an unlimited allowance. */
92
+ unlimited?: boolean;
89
93
  validityDays: number;
90
94
  price_cents: number;
91
95
  currency: string;
@@ -22,18 +22,18 @@ export function formatPrice(cents: number, currency = "EUR", locale = "en"): str
22
22
  /**
23
23
  * A plan's data allowance as a shopper reads it.
24
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.
25
+ * An explicit catalog flag proves unlimited; zero or missing data does not.
27
26
  */
28
- export function formatDataAllowance(dataGb: number): string {
29
- if (!Number.isFinite(dataGb) || dataGb <= 0) return "Unlimited data";
27
+ export function formatDataAllowance(dataGb: number, unlimited = false): string {
28
+ if (unlimited === true) return "Unlimited data";
29
+ if (!Number.isFinite(dataGb) || dataGb <= 0) return "Allowance not specified";
30
30
  if (dataGb < 1) return `${Math.round(dataGb * 1024)} MB data`;
31
31
  return `${Number.isInteger(dataGb) ? dataGb : dataGb.toFixed(1)} GB data`;
32
32
  }
33
33
 
34
34
  /** A plan's validity window, singular-aware. */
35
35
  export function formatValidity(validityDays: number): string {
36
- if (!Number.isFinite(validityDays) || validityDays <= 0) return "No expiry";
36
+ if (!Number.isFinite(validityDays) || validityDays <= 0) return "Validity not specified";
37
37
  if (validityDays === 1) return "1 day";
38
38
  if (validityDays % 7 === 0 && validityDays >= 7) {
39
39
  const weeks = validityDays / 7;
@@ -21,7 +21,7 @@ export const BRAND = {
21
21
  whatsapp: brand.supportWhatsapp,
22
22
  sms: brand.supportWhatsapp,
23
23
  statusUrl: brand.supportUrl,
24
- hoursLocal: "24/7",
24
+ hoursLocal: "We reply within one business day",
25
25
  responseTimeMinutes: 5,
26
26
  },
27
27
  social: brand.social,