@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.
- package/dist/{chunk-2ZWA4XML.js → chunk-KFIIIQ4J.js} +9 -6
- package/dist/{chunk-2ZWA4XML.js.map → chunk-KFIIIQ4J.js.map} +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/storefront/eslint.config.mjs +8 -1
- package/templates/storefront/package-lock.json +492 -654
- package/templates/storefront/package.json +19 -11
- package/templates/storefront/pnpm-lock.yaml +1391 -1592
- package/templates/storefront/pnpm-workspace.yaml +7 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +203 -114
- package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +11 -6
- package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +7 -4
- package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +7 -4
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +22 -17
- package/templates/storefront/src/app/contact/page.tsx +3 -3
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +4 -4
- package/templates/storefront/src/app/help/page.tsx +1 -1
- package/templates/storefront/src/app/shop/ShopClient.tsx +1 -1
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +3 -2
- package/templates/storefront/src/app/sitemap.ts +1 -0
- package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +4 -2
- package/templates/storefront/src/components/faq/FaqSection.tsx +1 -1
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +2 -2
- package/templates/storefront/src/components/landing/FinalCTA.tsx +1 -1
- package/templates/storefront/src/components/landing/HeroSection.tsx +6 -2
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +2 -1
- package/templates/storefront/src/components/theme/theme-provider.tsx +28 -17
- package/templates/storefront/src/lib/checkout-order-claim.ts +5 -14
- package/templates/storefront/src/lib/order-route-guard.ts +34 -0
- package/templates/storefront/src/middleware.ts +5 -0
- package/templates/storefront/src/vendor/carrier/client.ts +16 -2
- package/templates/storefront/src/vendor/carrier/types.ts +4 -0
- package/templates/storefront/src/vendor/geo/index.ts +5 -5
- 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
|
-
|
|
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}
|
|
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
|
|
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">
|
|
71
|
+
<p className="text-xs text-sand-600">{BRAND.support.hoursLocal}</p>
|
|
72
72
|
</div>
|
|
73
73
|
</div>
|
|
74
74
|
</footer>
|
|
@@ -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: "
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
-
*
|
|
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 (
|
|
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 "
|
|
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;
|