@carrierllc/mcp 0.13.41 → 0.13.47
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-6OAZJBLA.js → chunk-4XUAUQ3U.js} +67 -7
- package/dist/chunk-4XUAUQ3U.js.map +1 -0
- package/dist/cli.js +55 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.js +66 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/CLERK.md +23 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
- package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
- package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
- package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
- package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
- package/templates/storefront/src/app/dashboard/page.tsx +43 -23
- package/templates/storefront/src/app/globals.css +44 -5
- package/templates/storefront/src/app/help/page.tsx +2 -2
- package/templates/storefront/src/app/layout.tsx +5 -3
- package/templates/storefront/src/app/page.tsx +27 -7
- package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
- package/templates/storefront/src/app/shop/page.tsx +7 -2
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
- package/templates/storefront/src/brand.config.ts +16 -0
- package/templates/storefront/src/components/Providers.tsx +20 -1
- package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
- package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
- package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
- package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
- package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
- package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
- package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
- package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
- package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
- package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
- package/templates/storefront/src/components/theme/brand-style.ts +88 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
- package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
- package/templates/storefront/src/lib/buyer-error.ts +32 -0
- package/templates/storefront/src/lib/buyer-session.ts +41 -0
- package/templates/storefront/src/lib/catalog-countries.ts +179 -0
- package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
- package/templates/storefront/src/lib/checkout-copy.ts +89 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
- package/templates/storefront/src/lib/checkout-return.ts +18 -0
- package/templates/storefront/src/lib/clerk-instance.ts +89 -0
- package/templates/storefront/src/lib/order-reference.ts +15 -0
- package/templates/storefront/src/lib/request-origin.ts +28 -0
- package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
- package/templates/storefront/src/lib/tap-target.ts +17 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
- package/templates/storefront/src/middleware.ts +11 -0
- package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
- package/templates/storefront/src/vendor/carrier/client.ts +22 -4
- package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
- package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
- package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
- package/templates/storefront/src/vendor/carrier/types.ts +22 -0
- package/templates/storefront/src/vendor/geo/index.ts +15 -4
- package/dist/chunk-6OAZJBLA.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carrierllc/mcp",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.47",
|
|
4
4
|
"description": "Carrier MCP \u2014 natural-language control of MVNO/eSIM fleets via eSIMVault OCS. Stdio mode for direct integration with Claude Desktop, Cursor, Windsurf, and MCP-compatible clients. Ships the `carrier` CLI (plugin install + white-label eSIM storefront scaffold).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Carrier (Lifecycle Innovations Limited)",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Clerk instance switch
|
|
2
|
+
|
|
3
|
+
This template does not create or configure a Clerk application. Dev is the default. Production is a config switch for a later approval.
|
|
4
|
+
|
|
5
|
+
Set the switch with `NEXT_PUBLIC_CLERK_INSTANCE`.
|
|
6
|
+
|
|
7
|
+
| `NEXT_PUBLIC_CLERK_INSTANCE` | Publishable key | Secret key |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| unset, or `dev` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | `CLERK_SECRET_KEY` |
|
|
10
|
+
| `production` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION` | `CLERK_SECRET_KEY_PRODUCTION` |
|
|
11
|
+
|
|
12
|
+
Dev mode ignores the production key names even when they are present. Production mode does not fall back to the dev key names. Leave the production names empty until that instance exists.
|
|
13
|
+
|
|
14
|
+
`NEXT_PUBLIC_PASSWORD_RESET_ENABLED` controls the buyer reset path. Leave it unset to show Forgot password. Set it to `false` to hide that path on a storefront.
|
|
15
|
+
|
|
16
|
+
The sign-in title is `Sign in to` plus `NEXT_PUBLIC_BRAND_NAME`. Buyers are not shown an organization chooser: a session whose only pending task is `choose-organization` continues to `/dashboard`.
|
|
17
|
+
|
|
18
|
+
Buyers sign in and sign up on this shop. Set both of these so Clerk does not send them to its hosted pages:
|
|
19
|
+
|
|
20
|
+
- `NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in`
|
|
21
|
+
- `NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up`
|
|
22
|
+
|
|
23
|
+
`ClerkProvider` sets the same paths. A return path stays on this origin: a same-origin absolute URL is kept as its path, and that path is carried into Create account.
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
* Phone is NEVER required — user can skip.
|
|
20
20
|
*/
|
|
21
21
|
import { useCallback, useEffect, useRef, useState, FormEvent } from "react";
|
|
22
|
+
import brand from "@/brand.config";
|
|
23
|
+
import { buyerFacingError } from "@/lib/buyer-error";
|
|
22
24
|
import {
|
|
23
25
|
ANDROID_INSTALL_STEPS,
|
|
24
26
|
IPHONE_INSTALL_STEPS,
|
|
25
27
|
directInstallUrl,
|
|
26
28
|
manualActivationCode,
|
|
27
29
|
} from "@/lib/esim-install";
|
|
30
|
+
import { shortOrderReference, supportOrderHref } from "@/lib/order-reference";
|
|
28
31
|
import type { EsimOrder } from "@/vendor/carrier/types";
|
|
29
32
|
|
|
30
33
|
interface Props {
|
|
@@ -94,11 +97,9 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
94
97
|
setState({
|
|
95
98
|
phase: "error",
|
|
96
99
|
terminal,
|
|
97
|
-
message:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
? "We can't find this order on your account."
|
|
101
|
-
: "We could not load your eSIM details right now."),
|
|
100
|
+
message: terminal
|
|
101
|
+
? "We can't find this order on your account."
|
|
102
|
+
: "We could not load your eSIM details right now.",
|
|
102
103
|
});
|
|
103
104
|
return null;
|
|
104
105
|
}
|
|
@@ -194,13 +195,16 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
194
195
|
body: JSON.stringify({ phone }),
|
|
195
196
|
});
|
|
196
197
|
if (!res.ok) {
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
try {
|
|
199
|
+
await res.json();
|
|
200
|
+
} catch {
|
|
201
|
+
// The body is not buyer copy.
|
|
202
|
+
}
|
|
203
|
+
throw new Error("phone");
|
|
199
204
|
}
|
|
200
205
|
setSubmitted(true);
|
|
201
206
|
} catch (err) {
|
|
202
|
-
|
|
203
|
-
setSaveError(msg);
|
|
207
|
+
setSaveError(buyerFacingError("phone", err));
|
|
204
208
|
} finally {
|
|
205
209
|
setSaving(false);
|
|
206
210
|
}
|
|
@@ -231,7 +235,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
231
235
|
<h1 className="text-2xl font-bold text-sand-900 dark:text-sand-50">
|
|
232
236
|
{ready ? "Your eSIM is ready" : "Setting up your eSIM"}
|
|
233
237
|
</h1>
|
|
234
|
-
<p className="mt-2 text-sm text-sand-
|
|
238
|
+
<p className="mt-2 text-sm text-sand-700 dark:text-sand-200">Reference {shortOrderReference(orderId)}</p>
|
|
235
239
|
</div>
|
|
236
240
|
|
|
237
241
|
<div className="rounded-2xl border border-sand-200 dark:border-sand-800 bg-white dark:bg-sand-900 p-8 text-center mb-8">
|
|
@@ -248,8 +252,11 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
248
252
|
{state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}
|
|
249
253
|
</p>
|
|
250
254
|
<p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
|
|
251
|
-
<p className="text-sand-
|
|
252
|
-
Your payment is safe
|
|
255
|
+
<p className="text-sand-700 dark:text-sand-200 text-sm mb-4">
|
|
256
|
+
Your payment is safe.{" "}
|
|
257
|
+
<a className="inline-flex min-h-11 items-center font-medium text-[var(--brand-accent-text)]" href={supportOrderHref(brand.supportEmail, orderId)}>
|
|
258
|
+
Email {brand.supportEmail}
|
|
259
|
+
</a>
|
|
253
260
|
</p>
|
|
254
261
|
<div className="flex items-center justify-center gap-3">
|
|
255
262
|
{/* No "Try again" on a terminal answer: the same request would
|
|
@@ -263,7 +270,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
263
270
|
setState({ phase: "loading" });
|
|
264
271
|
void loadOrder();
|
|
265
272
|
}}
|
|
266
|
-
className="rounded-full bg-[var(--brand-accent)] px-5
|
|
273
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
|
|
267
274
|
>
|
|
268
275
|
Try again
|
|
269
276
|
</button>
|
|
@@ -272,8 +279,8 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
272
279
|
href="/contact"
|
|
273
280
|
className={
|
|
274
281
|
state.terminal
|
|
275
|
-
? "rounded-full bg-[var(--brand-accent)] px-5
|
|
276
|
-
: "rounded-full border border-sand-300 dark:border-sand-700 px-5
|
|
282
|
+
? "rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
|
|
283
|
+
: "rounded-full border border-sand-300 dark:border-sand-700 px-5 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
|
|
277
284
|
}
|
|
278
285
|
>
|
|
279
286
|
Contact support
|
|
@@ -300,13 +307,13 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
300
307
|
setState({ phase: "loading" });
|
|
301
308
|
setPollCycle((n) => n + 1);
|
|
302
309
|
}}
|
|
303
|
-
className="rounded-full border border-sand-300 dark:border-sand-700 px-5
|
|
310
|
+
className="rounded-full border border-sand-300 dark:border-sand-700 px-5 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
|
|
304
311
|
>
|
|
305
312
|
Check again
|
|
306
313
|
</button>
|
|
307
314
|
<a
|
|
308
315
|
href="/contact"
|
|
309
|
-
className="rounded-full bg-[var(--brand-accent)] px-5
|
|
316
|
+
className="rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
|
|
310
317
|
>
|
|
311
318
|
Contact support
|
|
312
319
|
</a>
|
|
@@ -337,7 +344,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
337
344
|
</p>
|
|
338
345
|
<a
|
|
339
346
|
href="/contact"
|
|
340
|
-
className="inline-block rounded-full bg-[var(--brand-accent)] px-5
|
|
347
|
+
className="inline-block rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
|
|
341
348
|
>
|
|
342
349
|
Contact support
|
|
343
350
|
</a>
|
|
@@ -390,7 +397,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
390
397
|
{ready && manualCode && (
|
|
391
398
|
<a
|
|
392
399
|
href={directInstallUrl(manualCode)}
|
|
393
|
-
className="mb-4 block w-full rounded-full bg-[var(--brand-accent)] px-6 py-3 text-center text-sm font-semibold text-
|
|
400
|
+
className="mb-4 block w-full rounded-full bg-[var(--brand-accent)] px-6 py-3 text-center text-sm font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
|
|
394
401
|
>
|
|
395
402
|
Install this eSIM on this iPhone
|
|
396
403
|
</a>
|
|
@@ -433,7 +440,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
433
440
|
<button
|
|
434
441
|
type="button"
|
|
435
442
|
onClick={() => void copyActivationCode(manualCode)}
|
|
436
|
-
className="mt-4 w-full rounded-lg border border-sand-300 dark:border-sand-700 px-4
|
|
443
|
+
className="mt-4 w-full rounded-lg border border-sand-300 dark:border-sand-700 px-4 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
|
|
437
444
|
>
|
|
438
445
|
{copied ? "Copied" : "Copy activation string"}
|
|
439
446
|
</button>
|
|
@@ -499,12 +506,12 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
499
506
|
value={phone}
|
|
500
507
|
onChange={(e) => setPhone(e.target.value)}
|
|
501
508
|
placeholder="+1 555 000 0000"
|
|
502
|
-
className="flex-1 rounded-lg border border-sand-200 dark:border-sand-800 bg-sand-50 dark:bg-sand-950 px-3
|
|
509
|
+
className="flex-1 rounded-lg border border-sand-200 dark:border-sand-800 bg-sand-50 dark:bg-sand-950 px-3 min-h-11 text-base text-sand-900 dark:text-sand-50 placeholder:text-sand-400 focus:border-[var(--brand-accent)]"
|
|
503
510
|
/>
|
|
504
511
|
<button
|
|
505
512
|
type="submit"
|
|
506
513
|
disabled={saving || !phone.trim()}
|
|
507
|
-
className="rounded-lg bg-[var(--brand-accent)] px-4
|
|
514
|
+
className="rounded-lg bg-[var(--brand-accent)] px-4 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
|
|
508
515
|
>
|
|
509
516
|
{saving ? "Saving…" : "Save"}
|
|
510
517
|
</button>
|
|
@@ -9,8 +9,8 @@ export const dynamic = "force-dynamic";
|
|
|
9
9
|
*
|
|
10
10
|
* The orderId is the Stripe Checkout session_id (or templateId in dev/mock mode).
|
|
11
11
|
*/
|
|
12
|
-
import { auth } from "@clerk/nextjs/server";
|
|
13
12
|
import { redirect } from "next/navigation";
|
|
13
|
+
import { buyerUserId } from "@/lib/buyer-session";
|
|
14
14
|
import { userOwnsOrder } from "@/lib/checkout-order-claim";
|
|
15
15
|
import { ActivateClient } from "./ActivateClient";
|
|
16
16
|
|
|
@@ -21,7 +21,7 @@ interface Props {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export default async function ActivatePage({ params }: Props) {
|
|
24
|
-
const
|
|
24
|
+
const userId = await buyerUserId();
|
|
25
25
|
if (!userId) redirect("/sign-in");
|
|
26
26
|
|
|
27
27
|
const { orderId } = await params;
|
|
@@ -1,29 +1,30 @@
|
|
|
1
|
-
import { auth } from "@clerk/nextjs/server";
|
|
2
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { buyerFacingError } from "@/lib/checkout-copy";
|
|
3
|
+
import { buyerUserId } from "@/lib/buyer-session";
|
|
3
4
|
import { claimCheckoutOrder } from "@/lib/checkout-order-claim";
|
|
4
5
|
|
|
5
6
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
6
|
-
const
|
|
7
|
+
const userId = await buyerUserId();
|
|
7
8
|
if (!userId) {
|
|
8
|
-
return NextResponse.json({ error:
|
|
9
|
+
return NextResponse.json({ error: buyerFacingError(401) }, { status: 401 });
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
let body: { sessionId?: string; templateId?: string };
|
|
12
13
|
try {
|
|
13
14
|
body = (await req.json()) as { sessionId?: string; templateId?: string };
|
|
14
15
|
} catch {
|
|
15
|
-
return NextResponse.json({ error:
|
|
16
|
+
return NextResponse.json({ error: buyerFacingError(400) }, { status: 400 });
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const sessionId = body.sessionId ?? "";
|
|
19
20
|
const templateId = body.templateId ?? "";
|
|
20
21
|
if (!sessionId || !templateId) {
|
|
21
|
-
return NextResponse.json({ error:
|
|
22
|
+
return NextResponse.json({ error: buyerFacingError(400) }, { status: 400 });
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const result = await claimCheckoutOrder(userId, sessionId, templateId);
|
|
25
26
|
if (!result.ok) {
|
|
26
|
-
return NextResponse.json({ error: result.
|
|
27
|
+
return NextResponse.json({ error: buyerFacingError(result.status) }, { status: result.status });
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
return NextResponse.json({ ok: true, orderId: result.orderId });
|
|
@@ -20,18 +20,20 @@
|
|
|
20
20
|
* The success / return URL encodes the templateId so the /checkout/success
|
|
21
21
|
* page can display the correct plan and prompt sign-up / sign-in.
|
|
22
22
|
*
|
|
23
|
-
* The line item is
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* The line item is a Stripe Price id. The session charges that Price's
|
|
24
|
+
* current `unit_amount`, which is also what the page renders. A catalog that
|
|
25
|
+
* fell back to sample plans is refused when Stripe is configured: those plans
|
|
26
|
+
* have no Price id, and charging one would take money for a product that
|
|
27
|
+
* cannot be fulfilled. A live secret is refused as well — this template
|
|
28
|
+
* charges in Stripe test mode only.
|
|
29
29
|
*/
|
|
30
30
|
import { auth, clerkClient } from "@clerk/nextjs/server";
|
|
31
31
|
import { NextRequest, NextResponse } from "next/server";
|
|
32
32
|
import { orderPurchaserEmail, userOwnsOrder } from "@/lib/checkout-order-claim";
|
|
33
33
|
import { fetchOrder, fetchTopUpOptions, fetchTemplateCatalog } from "@/vendor/carrier/client";
|
|
34
34
|
import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
|
|
35
|
+
import { checkoutPriceFields, isLiveStripeKey, isStripePriceId, overlayStripePrices } from "@/vendor/carrier/stripe-price";
|
|
36
|
+
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
35
37
|
|
|
36
38
|
interface GuestCheckoutBody {
|
|
37
39
|
templateId?: string;
|
|
@@ -45,6 +47,26 @@ interface GuestCheckoutBody {
|
|
|
45
47
|
topUpForOrderId?: string;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The catalog only carries `stripe_price_id` for a caller whose bearer equals
|
|
52
|
+
* the worker's CARRIER_STOREFRONT_API_KEY. With a different or unset key the
|
|
53
|
+
* catalog still answers 200 and every plan arrives without a Price id, so the
|
|
54
|
+
* guard refuses each sale. The refusal stays: there is no inline-amount
|
|
55
|
+
* fallback. This line makes the cause visible in the server log, with no
|
|
56
|
+
* secret in it.
|
|
57
|
+
*/
|
|
58
|
+
function logPlanNotSellable(plan: SkuTemplate | undefined): void {
|
|
59
|
+
if (plan && !isStripePriceId(plan.stripe_price_id)) {
|
|
60
|
+
console.error(
|
|
61
|
+
`[checkout] plan ${plan.id} has no Stripe Price id, so it cannot be sold. ` +
|
|
62
|
+
"The catalog omits it unless CARRIER_API_KEY on this storefront equals " +
|
|
63
|
+
"CARRIER_STOREFRONT_API_KEY on the Carrier worker; check both match.",
|
|
64
|
+
);
|
|
65
|
+
} else {
|
|
66
|
+
console.error("[checkout] plan is not sellable: the catalog row is incomplete");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
49
71
|
// Resolve server identity for every request, independent of client input.
|
|
50
72
|
// First purchases may remain anonymous; only top-ups require this identity.
|
|
@@ -113,7 +135,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
113
135
|
if (!base || !/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(slug)) {
|
|
114
136
|
return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
|
|
115
137
|
}
|
|
116
|
-
const
|
|
138
|
+
const catalogKey = process.env.CARRIER_API_KEY;
|
|
139
|
+
const response = await fetch(`${base}/api/hosted/${encodeURIComponent(slug)}`, {
|
|
140
|
+
cache: "no-store",
|
|
141
|
+
headers: catalogKey ? { Authorization: `Bearer ${catalogKey}` } : {},
|
|
142
|
+
});
|
|
117
143
|
if (!response.ok) return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
|
|
118
144
|
const shop = await response.json();
|
|
119
145
|
if (!shop || typeof shop !== "object" || Array.isArray(shop) ||
|
|
@@ -124,6 +150,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
124
150
|
return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
|
|
125
151
|
}
|
|
126
152
|
storefrontId = shop.id;
|
|
153
|
+
// A shop with its own connected Stripe account sells under that
|
|
154
|
+
// account. This template creates sessions on the platform account only,
|
|
155
|
+
// so a connected shop is refused until account-scoped checkout exists.
|
|
156
|
+
if (typeof shop.stripe_account_id === "string" && shop.stripe_account_id) {
|
|
157
|
+
console.error("[checkout] storefront has a connected Stripe account; platform-scoped checkout refused");
|
|
158
|
+
return NextResponse.json(
|
|
159
|
+
{ error: "That plan cannot be purchased right now. Please try again in a few minutes.", code: "plan_not_sellable" },
|
|
160
|
+
{ status: 503 },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
127
163
|
catalog = { source: "live" as const, templates: shop.plans };
|
|
128
164
|
} else {
|
|
129
165
|
catalog = await fetchTemplateCatalog();
|
|
@@ -143,6 +179,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
143
179
|
"NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed",
|
|
144
180
|
);
|
|
145
181
|
}
|
|
182
|
+
if (decision.code === "plan_not_sellable") {
|
|
183
|
+
logPlanNotSellable(catalog.templates.find((p: SkuTemplate) => p.id === templateId));
|
|
184
|
+
}
|
|
146
185
|
return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status });
|
|
147
186
|
}
|
|
148
187
|
|
|
@@ -155,12 +194,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
155
194
|
}
|
|
156
195
|
}
|
|
157
196
|
|
|
197
|
+
if (isLiveStripeKey(stripeKey)) {
|
|
198
|
+
console.error("[checkout] refusing a live Stripe key; test mode only");
|
|
199
|
+
return NextResponse.json(
|
|
200
|
+
{ error: "Checkout is unavailable right now. Please try again in a moment.", code: "stripe_live_refused" },
|
|
201
|
+
{ status: 503 },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
if (!stripeKey) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return NextResponse.json({ url: successUrl });
|
|
206
|
+
return NextResponse.json(
|
|
207
|
+
{ error: "Checkout is unavailable right now.", code: "stripe_unconfigured" },
|
|
208
|
+
{ status: 503 },
|
|
209
|
+
);
|
|
164
210
|
}
|
|
165
211
|
|
|
166
212
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
|
@@ -180,14 +226,23 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
|
180
226
|
return NextResponse.json({ error: "This order cannot be topped up" }, { status: 409 });
|
|
181
227
|
}
|
|
182
228
|
}
|
|
229
|
+
const [priced] = await overlayStripePrices([plan]);
|
|
230
|
+
const priceFields = checkoutPriceFields(priced ?? plan);
|
|
231
|
+
if (!priceFields) {
|
|
232
|
+
logPlanNotSellable(priced ?? plan);
|
|
233
|
+
return NextResponse.json(
|
|
234
|
+
{
|
|
235
|
+
error: "That plan cannot be purchased right now. Please try again in a few minutes.",
|
|
236
|
+
code: "plan_not_sellable",
|
|
237
|
+
},
|
|
238
|
+
{ status: 503 },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
183
242
|
const formBody = new URLSearchParams({
|
|
184
243
|
mode: "payment",
|
|
185
244
|
...(buyerEmail ? { customer_email: buyerEmail } : {}),
|
|
186
|
-
|
|
187
|
-
"line_items[0][price_data][unit_amount]": String(plan.price_cents),
|
|
188
|
-
"line_items[0][price_data][product_data][name]": plan.name,
|
|
189
|
-
"line_items[0][price_data][product_data][description]": plan.description,
|
|
190
|
-
"line_items[0][quantity]": "1",
|
|
245
|
+
...priceFields,
|
|
191
246
|
// Embedded sessions take `return_url` and reject `success_url` /
|
|
192
247
|
// `cancel_url`: there is no hosted page to come back from, and cancelling
|
|
193
248
|
// is just closing the form.
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* an order must say so; the one thing it must never do is draw a reassuring
|
|
10
10
|
* placeholder while the eSIM behind it does not exist.
|
|
11
11
|
*/
|
|
12
|
-
import { auth } from "@clerk/nextjs/server";
|
|
13
12
|
import { NextResponse } from "next/server";
|
|
13
|
+
import { buyerUserId } from "@/lib/buyer-session";
|
|
14
14
|
import { userOwnsOrder } from "@/lib/checkout-order-claim";
|
|
15
15
|
import { fetchOrder } from "@/vendor/carrier/client";
|
|
16
16
|
|
|
@@ -21,7 +21,7 @@ interface Context {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
24
|
-
const
|
|
24
|
+
const userId = await buyerUserId();
|
|
25
25
|
if (!userId) {
|
|
26
26
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
27
27
|
}
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* publishable key the browser goes to Stripe's hosted page and comes back.
|
|
15
15
|
* Both take money; the embedded one loses fewer people on the way.
|
|
16
16
|
*
|
|
17
|
-
* After payment the user lands on /checkout/success
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* After payment the user lands on /checkout/success. That page confirms the
|
|
18
|
+
* payment on its own. Saving the eSIM to an account is an inline step on the
|
|
19
|
+
* same page, and it does not start another charge. A mock session id is an
|
|
20
|
+
* error, not a confirmation.
|
|
20
21
|
*
|
|
21
22
|
* Every string on this page is read by a customer. It names no environment
|
|
22
23
|
* variable and gives no configuration instructions — an operator's deployment
|
|
@@ -30,6 +31,12 @@ import {
|
|
|
30
31
|
EmbeddedStripeCheckout,
|
|
31
32
|
canEmbedStripeCheckout,
|
|
32
33
|
} from "@/components/checkout/EmbeddedStripeCheckout";
|
|
34
|
+
import {
|
|
35
|
+
BuyerCopyError,
|
|
36
|
+
checkoutErrorCopy,
|
|
37
|
+
GENERIC_CHECKOUT_ERROR,
|
|
38
|
+
readCheckoutJson,
|
|
39
|
+
} from "@/lib/checkout-client-error";
|
|
33
40
|
import { conversionEvents } from "@/lib/conversion-events";
|
|
34
41
|
import { normalizeChannel } from "@/vendor/carrier/catalog-guard";
|
|
35
42
|
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
@@ -40,8 +47,6 @@ interface Props {
|
|
|
40
47
|
session: { url: string } | null;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
const GENERIC_CHECKOUT_ERROR = "Checkout is unavailable right now. Please try again in a moment.";
|
|
44
|
-
|
|
45
50
|
/**
|
|
46
51
|
* Where this visit came from, read off the landing URL.
|
|
47
52
|
*
|
|
@@ -94,19 +99,21 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
94
99
|
}),
|
|
95
100
|
})
|
|
96
101
|
.then(async (res) => {
|
|
102
|
+
let body: { error?: string; code?: string; url?: string; clientSecret?: string; sessionId?: string } | null;
|
|
103
|
+
try {
|
|
104
|
+
body = (await readCheckoutJson(res)) as typeof body;
|
|
105
|
+
} catch {
|
|
106
|
+
body = null;
|
|
107
|
+
}
|
|
108
|
+
if (!body || typeof body !== "object") {
|
|
109
|
+
throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
|
|
110
|
+
}
|
|
97
111
|
if (!res.ok) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
|
|
102
|
-
if (embed && err.code === "embed_unavailable") throw new EmbedUnavailable();
|
|
103
|
-
throw new Error(err.code && err.error ? err.error : GENERIC_CHECKOUT_ERROR);
|
|
112
|
+
if (embed && body.code === "embed_unavailable") throw new EmbedUnavailable();
|
|
113
|
+
if (body.code && body.error) throw new BuyerCopyError(body.error);
|
|
114
|
+
throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
|
|
104
115
|
}
|
|
105
|
-
return
|
|
106
|
-
url?: string;
|
|
107
|
-
clientSecret?: string;
|
|
108
|
-
sessionId?: string;
|
|
109
|
-
}>;
|
|
116
|
+
return body;
|
|
110
117
|
})
|
|
111
118
|
.then((data) => {
|
|
112
119
|
if (embed && data.clientSecret && data.sessionId) {
|
|
@@ -116,10 +123,13 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
116
123
|
return;
|
|
117
124
|
}
|
|
118
125
|
if (data.url) {
|
|
126
|
+
if (data.url.includes("session_id=mock")) {
|
|
127
|
+
throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
|
|
128
|
+
}
|
|
119
129
|
window.location.href = data.url;
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
122
|
-
throw new
|
|
132
|
+
throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
|
|
123
133
|
})
|
|
124
134
|
.catch((err: unknown) => {
|
|
125
135
|
if (err instanceof EmbedUnavailable && !embedAbandonedRef.current) {
|
|
@@ -130,7 +140,7 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
130
140
|
return;
|
|
131
141
|
}
|
|
132
142
|
guestCheckoutPlanIdRef.current = null;
|
|
133
|
-
setCheckoutError(err
|
|
143
|
+
setCheckoutError(checkoutErrorCopy(err));
|
|
134
144
|
setLoading(false);
|
|
135
145
|
});
|
|
136
146
|
};
|
|
@@ -140,7 +150,7 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
140
150
|
);
|
|
141
151
|
|
|
142
152
|
useEffect(() => {
|
|
143
|
-
if (!isLoaded) return;
|
|
153
|
+
if (!isLoaded || plan.price_unavailable === true) return;
|
|
144
154
|
|
|
145
155
|
conversionEvents.beginCheckout(plan.name, plan.price_cents / 100);
|
|
146
156
|
|
|
@@ -189,14 +199,30 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
189
199
|
startGuestCheckout(false);
|
|
190
200
|
}, [startGuestCheckout]);
|
|
191
201
|
|
|
202
|
+
if (plan.price_unavailable === true) {
|
|
203
|
+
return (
|
|
204
|
+
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
|
205
|
+
<p className="text-sand-700 dark:text-sand-200">
|
|
206
|
+
We can't show a price for this plan right now, so it can't be bought yet.
|
|
207
|
+
</p>
|
|
208
|
+
<a
|
|
209
|
+
href="/shop"
|
|
210
|
+
className="mt-6 inline-flex min-h-11 items-center rounded-full border border-sand-300 px-6 text-base text-sand-900 dark:border-sand-700 dark:text-sand-50"
|
|
211
|
+
>
|
|
212
|
+
← Back to shop
|
|
213
|
+
</a>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
if (checkoutError) {
|
|
193
219
|
return (
|
|
194
220
|
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
|
195
|
-
<p className="text-sand-700 dark:text-sand-200">{checkoutError}</p>
|
|
196
|
-
<p className="mt-2 text-
|
|
221
|
+
<p role="alert" className="text-sand-700 dark:text-sand-200">{checkoutError}</p>
|
|
222
|
+
<p className="mt-2 text-base dark:text-sand-200 text-sand-600">{plan.name}</p>
|
|
197
223
|
<a
|
|
198
224
|
href="/shop"
|
|
199
|
-
className="mt-6 inline-
|
|
225
|
+
className="mt-6 inline-flex min-h-11 items-center rounded-full border border-sand-300 px-6 text-base text-sand-900 dark:border-sand-700 dark:text-sand-50"
|
|
200
226
|
>
|
|
201
227
|
← Back to shop
|
|
202
228
|
</a>
|
|
@@ -210,7 +236,7 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
210
236
|
<h1 className="text-2xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
|
|
211
237
|
Almost there.
|
|
212
238
|
</h1>
|
|
213
|
-
<p className="mt-2 mb-8 text-
|
|
239
|
+
<p className="mt-2 mb-8 text-base text-sand-700 dark:text-sand-200">
|
|
214
240
|
{plan.name} — your QR code is ready the moment this clears.
|
|
215
241
|
</p>
|
|
216
242
|
<EmbeddedStripeCheckout
|
|
@@ -225,10 +251,10 @@ export function CheckoutClient({ plan, session }: Props) {
|
|
|
225
251
|
return (
|
|
226
252
|
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
|
227
253
|
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
|
|
228
|
-
<p className="text-sand-
|
|
254
|
+
<p className="text-sand-700 dark:text-sand-200">
|
|
229
255
|
{loading ? "Preparing your checkout…" : "Redirecting to payment…"}
|
|
230
256
|
</p>
|
|
231
|
-
<p className="mt-2 text-
|
|
257
|
+
<p className="mt-2 text-base dark:text-sand-200 text-sand-600">{plan.name}</p>
|
|
232
258
|
</div>
|
|
233
259
|
);
|
|
234
260
|
}
|