@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.
- package/dist/{chunk-IUYX4NKC.js → chunk-YQQUV7AE.js} +9 -6
- package/dist/{chunk-IUYX4NKC.js.map → chunk-YQQUV7AE.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 +532 -694
- package/templates/storefront/package.json +20 -12
- package/templates/storefront/pnpm-lock.yaml +1321 -1504
- package/templates/storefront/pnpm-workspace.yaml +7 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +174 -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/types.ts +4 -0
- package/templates/storefront/src/vendor/geo/index.ts +5 -5
- package/templates/storefront/src/vendor/ui/brand.ts +1 -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 {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
+
const result = await orderActionCall<{ cancelledAt?: string }>(guard.orderId, "cancel");
|
|
26
26
|
if (result.ok) {
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
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
|
|
25
|
-
if (lookup.ok
|
|
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 {
|
|
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
|
|
22
|
-
if (lookup.ok
|
|
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 [
|
|
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 (
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
<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.
|
|
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?
|
|
207
|
-
|
|
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
|
|
@@ -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
|
-
<
|
|
138
|
+
<Link href="/sign-in" className="text-[var(--brand-accent)] hover:underline">
|
|
138
139
|
Sign in
|
|
139
|
-
</
|
|
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
|
-
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
onCompleteRef.current = onComplete;
|
|
131
|
+
onUnavailableRef.current = onUnavailable;
|
|
132
|
+
}, [onComplete, onUnavailable]);
|
|
131
133
|
|
|
132
134
|
useEffect(() => {
|
|
133
135
|
if (!canEmbedStripeCheckout || !clientSecret) return;
|