@carrierllc/mcp 0.13.22 → 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-2ZWA4XML.js → chunk-YQQUV7AE.js} +9 -6
  2. package/dist/{chunk-2ZWA4XML.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 +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 +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
@@ -4,4 +4,11 @@ allowBuilds:
4
4
  unrs-resolver: true
5
5
  workerd: true
6
6
  overrides:
7
+ brace-expansion@1: 1.1.21
8
+ brace-expansion@2: 2.1.7
9
+ brace-expansion@5: 5.0.12
10
+ js-yaml: ^4.3.2
11
+ nanoid: ^3.3.18
12
+ postcss: ^8.5.23
13
+ sharp: ^0.35.4
7
14
  esbuild: ^0.28.1
@@ -27,8 +27,10 @@
27
27
  * "Visit — Calm" at EUR 4.99 when no such product exists is a real payment
28
28
  * against nothing, and no webhook downstream can fulfil it.
29
29
  */
30
+ import { auth, clerkClient } from "@clerk/nextjs/server";
30
31
  import { NextRequest, NextResponse } from "next/server";
31
- import { fetchTemplateCatalog } from "@/vendor/carrier/client";
32
+ import { orderPurchaserEmail, userOwnsOrder } from "@/lib/checkout-order-claim";
33
+ import { fetchOrder, fetchTopUpOptions, fetchTemplateCatalog } from "@/vendor/carrier/client";
32
34
  import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
33
35
 
34
36
  interface GuestCheckoutBody {
@@ -44,6 +46,15 @@ interface GuestCheckoutBody {
44
46
  }
45
47
 
46
48
  export async function POST(req: NextRequest): Promise<NextResponse> {
49
+ // Resolve server identity for every request, independent of client input.
50
+ // First purchases may remain anonymous; only top-ups require this identity.
51
+ let userId: string | null;
52
+ try {
53
+ ({ userId } = await auth());
54
+ } catch {
55
+ return NextResponse.json({ error: "Checkout service unavailable" }, { status: 503 });
56
+ }
57
+
47
58
  let body: GuestCheckoutBody;
48
59
  try {
49
60
  body = (await req.json()) as GuestCheckoutBody;
@@ -51,135 +62,184 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
51
62
  return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
52
63
  }
53
64
 
65
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
66
+ return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
67
+ }
68
+ const providedTopUp = Object.prototype.hasOwnProperty.call(body, "topUpForOrderId");
69
+ if (providedTopUp && (typeof body.topUpForOrderId !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(body.topUpForOrderId))) {
70
+ return NextResponse.json({ error: "Invalid top-up order reference" }, { status: 400 });
71
+ }
72
+ const topUpForOrderId = providedTopUp ? body.topUpForOrderId! : null;
54
73
  const { templateId } = body;
55
74
  if (!templateId) {
56
75
  return NextResponse.json({ error: "templateId is required" }, { status: 400 });
57
76
  }
58
77
 
59
- const channel = normalizeChannel(body.channel);
60
- const stripeKey = process.env.STRIPE_SECRET_KEY;
61
- const catalog = await fetchTemplateCatalog();
62
-
63
- const decision = decideGuestCheckout({
64
- catalog,
65
- templateId,
66
- stripeConfigured: Boolean(stripeKey),
67
- });
68
- if (!decision.ok) {
69
- if (decision.code === "catalog_degraded") {
70
- // Loud on the server, calm to the customer. This is the state where a
71
- // storefront is live, taking traffic, and cannot tell what it sells.
72
- console.error(
73
- `[checkout] refusing to sell from a sample catalog (reason=${catalog.reason ?? "unknown"}) — ` +
74
- "NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed",
75
- );
78
+ try {
79
+ let buyerEmail: string | null = null;
80
+ if (topUpForOrderId) {
81
+ if (!userId) return NextResponse.json({ error: "Sign in to top up" }, { status: 401 });
82
+ if (!process.env.STRIPE_SECRET_KEY || !process.env.CARRIER_API_KEY || !process.env.NEXT_PUBLIC_CARRIER_API_URL) {
83
+ return NextResponse.json({ error: "Top-up service unavailable" }, { status: 503 });
84
+ }
85
+ if (!(await userOwnsOrder(userId, topUpForOrderId))) {
86
+ return NextResponse.json({ error: "This order is linked to another account" }, { status: 403 });
87
+ }
88
+ buyerEmail = await orderPurchaserEmail(topUpForOrderId);
89
+ const client = await clerkClient();
90
+ const user = await client.users.getUser(userId);
91
+ if (!buyerEmail || !user.emailAddresses.some((address) =>
92
+ address.emailAddress.toLowerCase() === buyerEmail!.toLowerCase() && address.verification?.status === "verified")) {
93
+ return NextResponse.json({ error: "Verify the email that bought this order" }, { status: 403 });
94
+ }
76
95
  }
77
- return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status });
78
- }
79
-
80
- const plan = decision.plan;
96
+ if (topUpForOrderId) {
97
+ const lookup = await fetchOrder(topUpForOrderId);
98
+ if (!lookup.ok) return NextResponse.json({ error: "Order unavailable" }, { status: lookup.reason === "not-found" ? 404 : 503 });
99
+ if (lookup.order.orderId !== topUpForOrderId || lookup.order.status !== "ready" || lookup.order.cancelledAt || !/^89[0-9]{16,20}$/.test(lookup.order.iccid ?? "")) {
100
+ return NextResponse.json({ error: "This order cannot be topped up" }, { status: 409 });
101
+ }
102
+ }
103
+ const channel = normalizeChannel(body.channel);
104
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
105
+ const catalog = await fetchTemplateCatalog();
81
106
 
82
- if (!stripeKey) {
83
- // No Stripe configured — return a mock URL so the storefront still builds
84
- // and renders without credentials. No money moves on this path.
85
- const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
86
- const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`;
87
- return NextResponse.json({ url: successUrl });
88
- }
107
+ const decision = decideGuestCheckout({
108
+ catalog,
109
+ templateId,
110
+ stripeConfigured: Boolean(stripeKey),
111
+ });
112
+ if (!decision.ok) {
113
+ if (decision.code === "catalog_degraded") {
114
+ // Loud on the server, calm to the customer. This is the state where a
115
+ // storefront is live, taking traffic, and cannot tell what it sells.
116
+ console.error(
117
+ `[checkout] refusing to sell from a sample catalog (reason=${catalog.reason ?? "unknown"}) — ` +
118
+ "NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed",
119
+ );
120
+ }
121
+ return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status });
122
+ }
89
123
 
90
- const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
91
- // success_url carries templateId so the success page knows which plan was purchased
92
- const successUrl =
93
- `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}`;
94
- const cancelUrl = `${appUrl}/shop`;
95
- const embedded = body.uiMode === "embedded";
96
- // Same charset gate the rest of the storefront applies to a Stripe session id.
97
- const topUpForOrderId =
98
- typeof body.topUpForOrderId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(body.topUpForOrderId)
99
- ? body.topUpForOrderId
100
- : null;
124
+ const plan = decision.plan;
125
+ if (topUpForOrderId) {
126
+ const eligible = await fetchTopUpOptions(topUpForOrderId);
127
+ if (!eligible.ok) return NextResponse.json({ error: "Top-up options unavailable" }, { status: 503 });
128
+ if (!eligible.options.some((option) => option?.id === templateId)) {
129
+ return NextResponse.json({ error: "This plan is not eligible for this eSIM" }, { status: 400 });
130
+ }
131
+ }
101
132
 
102
- const formBody = new URLSearchParams({
103
- mode: "payment",
104
- "line_items[0][price_data][currency]": plan.currency.toLowerCase(),
105
- "line_items[0][price_data][unit_amount]": String(plan.price_cents),
106
- "line_items[0][price_data][product_data][name]": plan.name,
107
- "line_items[0][price_data][product_data][description]": plan.description,
108
- "line_items[0][quantity]": "1",
109
- // Embedded sessions take `return_url` and reject `success_url` /
110
- // `cancel_url`: there is no hosted page to come back from, and cancelling
111
- // is just closing the form.
112
- ...(embedded
113
- ? {
114
- ui_mode: "embedded",
115
- redirect_on_completion: "if_required",
116
- return_url: successUrl,
117
- }
118
- : { success_url: successUrl, cancel_url: cancelUrl }),
119
- // Store templateId in BOTH session and payment_intent metadata so the
120
- // provisioning webhook can read it. The `checkout.session.completed` event
121
- // exposes `session.metadata` (NOT `payment_intent.metadata`), so the
122
- // session-level copy is what fulfilment keys off to provision the eSIM —
123
- // without it the plan is lost and the order is stuck "activating".
124
- "metadata[templateId]": templateId,
125
- "payment_intent_data[metadata][templateId]": templateId,
126
- // Where the sale came from. Written alongside templateId and by the same
127
- // reasoning: attribution recovered later from Stripe reports is guesswork,
128
- // and the session is the only record that survives the redirect.
129
- "metadata[channel]": channel,
130
- "payment_intent_data[metadata][channel]": channel,
131
- // Allow guest checkout (no Stripe account required)
132
- "payment_method_collection": "always",
133
- // Session-level and payment-intent-level, for the same reason templateId
134
- // is: `checkout.session.completed` only exposes session metadata, and a
135
- // top-up that loses its target order provisions a brand-new eSIM.
136
- ...(topUpForOrderId
137
- ? {
138
- "metadata[topUpForOrderId]": topUpForOrderId,
139
- "payment_intent_data[metadata][topUpForOrderId]": topUpForOrderId,
140
- }
141
- : {}),
142
- });
133
+ if (!stripeKey) {
134
+ // No Stripe configured — return a mock URL so the storefront still builds
135
+ // and renders without credentials. No money moves on this path.
136
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
137
+ const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`;
138
+ return NextResponse.json({ url: successUrl });
139
+ }
143
140
 
144
- const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
145
- method: "POST",
146
- headers: {
147
- Authorization: `Bearer ${stripeKey}`,
148
- "Content-Type": "application/x-www-form-urlencoded",
149
- },
150
- body: formBody.toString(),
151
- });
141
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
142
+ // success_url carries templateId so the success page knows which plan was purchased
143
+ const topUpQuery = topUpForOrderId ? `&topUpForOrderId=${encodeURIComponent(topUpForOrderId)}` : "";
144
+ const successUrl =
145
+ `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}${topUpQuery}`;
146
+ const cancelUrl = topUpForOrderId
147
+ ? `${appUrl}/shop?topUpForOrderId=${encodeURIComponent(topUpForOrderId)}`
148
+ : `${appUrl}/shop`;
149
+ const embedded = body.uiMode === "embedded";
152
150
 
153
- if (!resp.ok) {
154
- // Stripe's message names our parameters and our account. It goes to the
155
- // log, not to the customer.
156
- const err = (await resp.json().catch(() => ({}))) as { error?: { message?: string } };
157
- console.error(`[checkout] Stripe session creation failed: ${err.error?.message ?? resp.status}`);
158
- return NextResponse.json(
159
- { error: "We could not start checkout. Please try again in a moment.", code: "stripe_error" },
160
- { status: 502 },
161
- );
162
- }
151
+ if (topUpForOrderId) {
152
+ const fresh = await fetchOrder(topUpForOrderId);
153
+ if (!fresh.ok) return NextResponse.json({ error: "Order unavailable" }, { status: 503 });
154
+ if (fresh.order.orderId !== topUpForOrderId || fresh.order.status !== "ready" || fresh.order.cancelledAt || !/^89[0-9]{16,20}$/.test(fresh.order.iccid ?? "")) {
155
+ return NextResponse.json({ error: "This order cannot be topped up" }, { status: 409 });
156
+ }
157
+ }
158
+ const formBody = new URLSearchParams({
159
+ mode: "payment",
160
+ ...(buyerEmail ? { customer_email: buyerEmail } : {}),
161
+ "line_items[0][price_data][currency]": plan.currency.toLowerCase(),
162
+ "line_items[0][price_data][unit_amount]": String(plan.price_cents),
163
+ "line_items[0][price_data][product_data][name]": plan.name,
164
+ "line_items[0][price_data][product_data][description]": plan.description,
165
+ "line_items[0][quantity]": "1",
166
+ // Embedded sessions take `return_url` and reject `success_url` /
167
+ // `cancel_url`: there is no hosted page to come back from, and cancelling
168
+ // is just closing the form.
169
+ ...(embedded
170
+ ? {
171
+ ui_mode: "embedded",
172
+ redirect_on_completion: "if_required",
173
+ return_url: successUrl,
174
+ }
175
+ : { success_url: successUrl, cancel_url: cancelUrl }),
176
+ // Store templateId in BOTH session and payment_intent metadata so the
177
+ // provisioning webhook can read it. The `checkout.session.completed` event
178
+ // exposes `session.metadata` (NOT `payment_intent.metadata`), so the
179
+ // session-level copy is what fulfilment keys off to provision the eSIM —
180
+ // without it the plan is lost and the order is stuck "activating".
181
+ "metadata[templateId]": templateId,
182
+ "payment_intent_data[metadata][templateId]": templateId,
183
+ // Where the sale came from. Written alongside templateId and by the same
184
+ // reasoning: attribution recovered later from Stripe reports is guesswork,
185
+ // and the session is the only record that survives the redirect.
186
+ "metadata[channel]": channel,
187
+ "payment_intent_data[metadata][channel]": channel,
188
+ // Allow guest checkout (no Stripe account required)
189
+ "payment_method_collection": "always",
190
+ // Session-level and payment-intent-level, for the same reason templateId
191
+ // is: `checkout.session.completed` only exposes session metadata, and a
192
+ // top-up that loses its target order provisions a brand-new eSIM.
193
+ ...(topUpForOrderId
194
+ ? {
195
+ "metadata[topUpForOrderId]": topUpForOrderId,
196
+ "payment_intent_data[metadata][topUpForOrderId]": topUpForOrderId,
197
+ }
198
+ : {}),
199
+ });
163
200
 
164
- const session = (await resp.json()) as {
165
- id?: string;
166
- url?: string;
167
- client_secret?: string;
168
- };
201
+ const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
202
+ method: "POST",
203
+ headers: {
204
+ Authorization: `Bearer ${stripeKey}`,
205
+ "Content-Type": "application/x-www-form-urlencoded",
206
+ },
207
+ body: formBody.toString(),
208
+ });
169
209
 
170
- if (embedded) {
171
- if (!session.client_secret || !session.id) {
172
- // Stripe accepted the session but did not return what an embedded form
173
- // needs. Say so rather than shipping an empty iframe — the client falls
174
- // back to the hosted page on this code.
175
- console.error("[checkout] embedded session has no client_secret");
210
+ if (!resp.ok) {
211
+ // Stripe's message names our parameters and our account. It goes to the
212
+ // log, not to the customer.
213
+ const err = (await resp.json().catch(() => ({}))) as { error?: { message?: string } };
214
+ console.error(`[checkout] Stripe session creation failed: ${err.error?.message ?? resp.status}`);
176
215
  return NextResponse.json(
177
- { error: "We could not start checkout. Please try again in a moment.", code: "embed_unavailable" },
216
+ { error: "We could not start checkout. Please try again in a moment.", code: "stripe_error" },
178
217
  { status: 502 },
179
218
  );
180
219
  }
181
- return NextResponse.json({ clientSecret: session.client_secret, sessionId: session.id });
182
- }
183
220
 
184
- return NextResponse.json({ url: session.url });
221
+ const session = (await resp.json()) as {
222
+ id?: string;
223
+ url?: string;
224
+ client_secret?: string;
225
+ };
226
+
227
+ if (embedded) {
228
+ if (!session.client_secret || !session.id) {
229
+ // Stripe accepted the session but did not return what an embedded form
230
+ // needs. Say so rather than shipping an empty iframe — the client falls
231
+ // back to the hosted page on this code.
232
+ console.error("[checkout] embedded session has no client_secret");
233
+ return NextResponse.json(
234
+ { error: "We could not start checkout. Please try again in a moment.", code: "embed_unavailable" },
235
+ { status: 502 },
236
+ );
237
+ }
238
+ return NextResponse.json({ clientSecret: session.client_secret, sessionId: session.id });
239
+ }
240
+
241
+ return NextResponse.json({ url: session.url });
242
+ } catch {
243
+ return NextResponse.json({ error: "Checkout service unavailable" }, { status: 503 });
244
+ }
185
245
  }
@@ -9,8 +9,8 @@
9
9
  * history all survive a cancellation and the customer can still read them.
10
10
  */
11
11
  import { NextResponse } from "next/server";
12
- import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
13
- import { cancelOrder } from "@/vendor/carrier/client";
12
+ import { fulfilmentFailureResponse, guardOrderRoute, orderActionCall } from "@/lib/order-route-guard";
13
+
14
14
 
15
15
  export const dynamic = "force-dynamic";
16
16
 
@@ -22,11 +22,16 @@ export async function POST(_req: Request, ctx: Context): Promise<NextResponse> {
22
22
  const guard = await guardOrderRoute(ctx.params);
23
23
  if (!guard.ok) return guard.response;
24
24
 
25
- const result = await cancelOrder(guard.orderId);
25
+ const result = await orderActionCall<{ cancelledAt?: string }>(guard.orderId, "cancel");
26
26
  if (result.ok) {
27
- return NextResponse.json({
28
- cancelledAt: result.cancelledAt ?? new Date().toISOString(),
29
- });
27
+ const cancelledAt = result.data?.cancelledAt;
28
+ if (typeof cancelledAt !== "string" || !Number.isFinite(Date.parse(cancelledAt))) {
29
+ return NextResponse.json(
30
+ { error: "Cancellation was not confirmed. Reload before trying again." },
31
+ { status: 502 },
32
+ );
33
+ }
34
+ return NextResponse.json({ cancelledAt });
30
35
  }
31
36
 
32
37
  return fulfilmentFailureResponse(result.reason, "cancel this plan", guard.orderId);
@@ -8,8 +8,8 @@
8
8
  * dashboard says so rather than showing a button that refuses on tap.
9
9
  */
10
10
  import { NextResponse } from "next/server";
11
- import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
12
- import { fetchTopUpOptions } from "@/vendor/carrier/client";
11
+ import { fulfilmentFailureResponse, guardOrderRoute, orderActionCall } from "@/lib/order-route-guard";
12
+ import type { TopUpOption } from "@/vendor/carrier/types";
13
13
 
14
14
  export const dynamic = "force-dynamic";
15
15
 
@@ -21,8 +21,11 @@ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
21
21
  const guard = await guardOrderRoute(ctx.params);
22
22
  if (!guard.ok) return guard.response;
23
23
 
24
- const lookup = await fetchTopUpOptions(guard.orderId);
25
- if (lookup.ok) return NextResponse.json({ options: lookup.options });
24
+ const lookup = await orderActionCall<TopUpOption[]>(guard.orderId, "top-up-options");
25
+ if (lookup.ok && !Array.isArray(lookup.data)) {
26
+ return fulfilmentFailureResponse("upstream-error", "load top-up options", guard.orderId);
27
+ }
28
+ if (lookup.ok) return NextResponse.json({ options: lookup.data });
26
29
 
27
30
  return fulfilmentFailureResponse(lookup.reason, "load top-up options", guard.orderId);
28
31
  }
@@ -5,8 +5,8 @@
5
5
  * they have already claimed.
6
6
  */
7
7
  import { NextResponse } from "next/server";
8
- import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
9
- import { fetchUsage } from "@/vendor/carrier/client";
8
+ import { fulfilmentFailureResponse, guardOrderRoute, orderActionCall } from "@/lib/order-route-guard";
9
+ import type { EsimUsage } from "@/vendor/carrier/types";
10
10
 
11
11
  export const dynamic = "force-dynamic";
12
12
 
@@ -18,8 +18,11 @@ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
18
18
  const guard = await guardOrderRoute(ctx.params);
19
19
  if (!guard.ok) return guard.response;
20
20
 
21
- const lookup = await fetchUsage(guard.orderId);
22
- if (lookup.ok) return NextResponse.json({ usage: lookup.usage });
21
+ const lookup = await orderActionCall<EsimUsage>(guard.orderId, "usage");
22
+ if (lookup.ok && typeof lookup.data?.usedBytes !== "number") {
23
+ return fulfilmentFailureResponse("upstream-error", "read your usage", guard.orderId);
24
+ }
25
+ if (lookup.ok) return NextResponse.json({ usage: lookup.data });
23
26
 
24
27
  return fulfilmentFailureResponse(lookup.reason, "read your usage", guard.orderId);
25
28
  }
@@ -40,7 +40,10 @@ export function CheckoutSuccessClient({
40
40
 
41
41
  const activateUrl = `/activate/${encodeURIComponent(orderId)}`;
42
42
  const [mode, setMode] = useState<AuthMode>("signup");
43
- const [step, setStep] = useState<FlowStep>("idle");
43
+ const [flowStep, setStep] = useState<FlowStep>("idle");
44
+ const step = flowStep === "idle" && authLoaded
45
+ ? (isSignedIn ? "redirecting" : "credentials")
46
+ : flowStep;
44
47
  const [email, setEmail] = useState(purchaserEmail ?? "");
45
48
  const [password, setPassword] = useState("");
46
49
  const [code, setCode] = useState("");
@@ -48,10 +51,7 @@ export function CheckoutSuccessClient({
48
51
  const [loading, setLoading] = useState(false);
49
52
  const emailLocked = Boolean(purchaserEmail);
50
53
 
51
- const claimOrderAndRedirect = useCallback(async (afterAuth = false) => {
52
- setStep("redirecting");
53
- setError(null);
54
- try {
54
+ const claimOrderAndRedirect = useCallback(async () => {
55
55
  const res = await fetch("/api/checkout/claim", {
56
56
  method: "POST",
57
57
  headers: { "Content-Type": "application/json" },
@@ -62,21 +62,26 @@ export function CheckoutSuccessClient({
62
62
  throw new Error(err.error ?? "Failed to link your purchase to this account");
63
63
  }
64
64
  router.replace(activateUrl);
65
- } catch (err) {
66
- setStep(afterAuth || isSignedIn ? "claim-error" : "credentials");
67
- setError(err instanceof Error ? err.message : "Failed to link your purchase to this account");
68
- }
69
- }, [activateUrl, isSignedIn, router, sessionId, templateId]);
65
+ }, [activateUrl, router, sessionId, templateId]);
66
+
67
+ const handleClaimError = useCallback((err: unknown) => {
68
+ setStep("claim-error");
69
+ setError(err instanceof Error ? err.message : "Failed to link your purchase to this account");
70
+ }, []);
71
+
72
+ async function startClaimAndRedirect() {
73
+ setStep("redirecting");
74
+ setError(null);
75
+ await claimOrderAndRedirect().catch(handleClaimError);
76
+ }
70
77
 
71
78
  // If already signed in, claim the paid order before activating.
72
79
  useEffect(() => {
73
80
  if (!authLoaded) return;
74
81
  if (isSignedIn) {
75
- void claimOrderAndRedirect(true);
76
- } else {
77
- setStep("credentials");
82
+ void claimOrderAndRedirect().catch(handleClaimError);
78
83
  }
79
- }, [authLoaded, isSignedIn, claimOrderAndRedirect]);
84
+ }, [authLoaded, isSignedIn, claimOrderAndRedirect, handleClaimError]);
80
85
 
81
86
  function clerkErrMsg(err: unknown): string {
82
87
  const e = err as { errors?: Array<{ longMessage?: string; message?: string }> };
@@ -119,7 +124,7 @@ export function CheckoutSuccessClient({
119
124
  const completion = await completeEmailSignUpAfterVerify(signUp, result);
120
125
  if (completion.status === "complete") {
121
126
  await setActive({ session: completion.sessionId });
122
- await claimOrderAndRedirect(true);
127
+ await startClaimAndRedirect();
123
128
  } else {
124
129
  setError(completion.message);
125
130
  }
@@ -148,7 +153,7 @@ export function CheckoutSuccessClient({
148
153
  });
149
154
  if (result.status === "complete" && result.createdSessionId) {
150
155
  await setActive({ session: result.createdSessionId });
151
- await claimOrderAndRedirect(true);
156
+ await startClaimAndRedirect();
152
157
  } else {
153
158
  setError("Sign-in incomplete. Please try again.");
154
159
  }
@@ -190,7 +195,7 @@ export function CheckoutSuccessClient({
190
195
  )}
191
196
  <button
192
197
  type="button"
193
- onClick={() => void claimOrderAndRedirect(true)}
198
+ onClick={() => void startClaimAndRedirect()}
194
199
  className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
195
200
  >
196
201
  Try again
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import brand from "@/brand.config";
3
- import { buildSupportLink } from "@/vendor/ui/brand";
3
+ import { BRAND, buildSupportLink } from "@/vendor/ui/brand";
4
4
 
5
5
  export const metadata = { title: "Contact" };
6
6
 
@@ -11,7 +11,7 @@ export default function ContactPage() {
11
11
  Get in touch
12
12
  </h1>
13
13
  <p className="mt-2 mb-10 text-sand-600 dark:text-sand-400">
14
- Real humans. 24/7. Pick the channel you already have open.
14
+ Pick the channel you already have open.
15
15
  </p>
16
16
  <div className="space-y-3">
17
17
  <Link
@@ -21,7 +21,7 @@ export default function ContactPage() {
21
21
  className="flex items-center justify-between rounded-2xl border border-sand-200 bg-white px-5 py-4 text-sand-900 dark:border-sand-800 dark:bg-sand-900 dark:text-sand-50"
22
22
  >
23
23
  <span className="font-medium">WhatsApp</span>
24
- <span className="text-sm text-sand-500">24/7</span>
24
+ <span className="text-sm text-sand-500">{BRAND.support.hoursLocal}</span>
25
25
  </Link>
26
26
  <Link
27
27
  href={buildSupportLink("sms")}
@@ -104,7 +104,7 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
104
104
  });
105
105
  const payload = await readJson<{ url?: string }>(res);
106
106
  if (!res.ok || !payload.url) throw new Error(payload.error ?? GENERIC_ERROR);
107
- window.location.href = payload.url;
107
+ window.location.assign(payload.url);
108
108
  } catch (err) {
109
109
  setBuying(null);
110
110
  setTopUps({
@@ -171,7 +171,7 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
171
171
  {option.name}
172
172
  </p>
173
173
  <p className="text-xs text-sand-500">
174
- {formatDataAllowance(option.dataGb)} · {formatValidity(option.validityDays)}
174
+ {formatDataAllowance(option.dataGb, option.unlimited)} · {formatValidity(option.validityDays)}
175
175
  </p>
176
176
  </div>
177
177
  <button
@@ -203,8 +203,8 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
203
203
  ) : confirmingCancel ? (
204
204
  <div>
205
205
  <p className="mb-3 text-sm text-sand-700 dark:text-sand-300">
206
- Cancel this plan? It stops renewing. Data already paid for stays on the eSIM
207
- until it expires, and this cannot be undone from here.
206
+ Cancel this plan? This blocks future top-ups on this eSIM. It does not refund
207
+ payment, delete the eSIM from your device, or release its network profile.
208
208
  </p>
209
209
  <div className="flex flex-wrap gap-3">
210
210
  <button
@@ -11,7 +11,7 @@ export default function HelpPage() {
11
11
  Help
12
12
  </h1>
13
13
  <p className="mt-2 text-sand-600 dark:text-sand-400">
14
- Get connected in under five minutes. Humans on WhatsApp, 24/7.
14
+ Get connected in under five minutes.
15
15
  </p>
16
16
  </div>
17
17
  <HowItWorks />
@@ -103,7 +103,7 @@ export function ShopClient({ plans }: { plans: SkuTemplate[] }) {
103
103
  </h2>
104
104
  <p className="mt-1 text-sm text-sand-500">
105
105
  {selected
106
- ? `${formatDataAllowance(selected.dataGb)} · ${formatValidity(selected.validityDays)}`
106
+ ? `${formatDataAllowance(selected.dataGb, selected.unlimited)} · ${formatValidity(selected.validityDays)}`
107
107
  : "Choose a plan on the left and the total appears here."}
108
108
  </p>
109
109
 
@@ -9,6 +9,7 @@
9
9
  * useSignUp() → { signUp, isLoaded }
10
10
  * useClerk() → { setActive }
11
11
  */
12
+ import Link from "next/link";
12
13
  import { useState, FormEvent } from "react";
13
14
  import { useSignUp } from "@clerk/nextjs";
14
15
  import { useClerk } from "@clerk/nextjs";
@@ -134,9 +135,9 @@ export function StorefrontSignUpClient() {
134
135
  </form>
135
136
  <p className="mt-4 text-center text-xs text-sand-400">
136
137
  Already have an account?{" "}
137
- <a href="/sign-in" className="text-[var(--brand-accent)] hover:underline">
138
+ <Link href="/sign-in" className="text-[var(--brand-accent)] hover:underline">
138
139
  Sign in
139
- </a>
140
+ </Link>
140
141
  </p>
141
142
  </>
142
143
  )}
@@ -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;