@carrierllc/mcp 0.13.21 → 0.13.24

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-IUYX4NKC.js → chunk-YQQUV7AE.js} +9 -6
  2. package/dist/{chunk-IUYX4NKC.js.map → chunk-YQQUV7AE.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 +532 -694
  8. package/templates/storefront/package.json +20 -12
  9. package/templates/storefront/pnpm-lock.yaml +1321 -1504
  10. package/templates/storefront/pnpm-workspace.yaml +7 -0
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +174 -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/types.ts +4 -0
  33. package/templates/storefront/src/vendor/geo/index.ts +5 -5
  34. package/templates/storefront/src/vendor/ui/brand.ts +1 -1
@@ -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([
@@ -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,