@carrierllc/mcp 0.11.17 → 0.13.0

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 (39) hide show
  1. package/dist/{chunk-EHGZRVSO.js → chunk-ZUUC3J4J.js} +26 -3
  2. package/dist/chunk-ZUUC3J4J.js.map +1 -0
  3. package/dist/cli.js +397 -16
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +1 -1
  7. package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
  8. package/plugin/carrier/skills/storefront/SKILL.md +2 -2
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +205 -17
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
  12. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
  13. package/templates/storefront/src/app/api/orders/[orderId]/email/route.ts +73 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
  15. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  16. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  17. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  18. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  19. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  20. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  21. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  22. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  24. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  25. package/templates/storefront/src/brand.config.ts +19 -0
  26. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  27. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  28. package/templates/storefront/src/lib/checkout-order-claim.ts +113 -8
  29. package/templates/storefront/src/lib/esim-install.ts +74 -0
  30. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  31. package/templates/storefront/src/lib/qr-delivery-email.ts +106 -0
  32. package/templates/storefront/src/middleware.ts +1 -1
  33. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  34. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  35. package/templates/storefront/src/vendor/email/qr-delivery.ts +254 -0
  36. package/templates/storefront/src/vendor/email/resend.ts +140 -0
  37. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  38. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  39. package/dist/chunk-EHGZRVSO.js.map +0 -1
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /api/orders/[orderId]/top-up-options
3
+ *
4
+ * What more data on this eSIM would cost. Signed-in callers only, and only for
5
+ * an order they have already claimed.
6
+ *
7
+ * An empty list is a successful answer: this plan cannot be topped up. The
8
+ * dashboard says so rather than showing a button that refuses on tap.
9
+ */
10
+ import { NextResponse } from "next/server";
11
+ import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
12
+ import { fetchTopUpOptions } from "@/vendor/carrier/client";
13
+
14
+ export const dynamic = "force-dynamic";
15
+
16
+ interface Context {
17
+ params: Promise<{ orderId: string }>;
18
+ }
19
+
20
+ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
21
+ const guard = await guardOrderRoute(ctx.params);
22
+ if (!guard.ok) return guard.response;
23
+
24
+ const lookup = await fetchTopUpOptions(guard.orderId);
25
+ if (lookup.ok) return NextResponse.json({ options: lookup.options });
26
+
27
+ return fulfilmentFailureResponse(lookup.reason, "load top-up options", guard.orderId);
28
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * GET /api/orders/[orderId]/usage
3
+ *
4
+ * The usage meter's data source. Signed-in callers only, and only for an order
5
+ * they have already claimed.
6
+ */
7
+ import { NextResponse } from "next/server";
8
+ import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
9
+ import { fetchUsage } from "@/vendor/carrier/client";
10
+
11
+ export const dynamic = "force-dynamic";
12
+
13
+ interface Context {
14
+ params: Promise<{ orderId: string }>;
15
+ }
16
+
17
+ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
18
+ const guard = await guardOrderRoute(ctx.params);
19
+ if (!guard.ok) return guard.response;
20
+
21
+ const lookup = await fetchUsage(guard.orderId);
22
+ if (lookup.ok) return NextResponse.json({ usage: lookup.usage });
23
+
24
+ return fulfilmentFailureResponse(lookup.reason, "read your usage", guard.orderId);
25
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * POST /api/profile/phone
3
3
  *
4
- * MANGO-8/9: Saves an optional phone number to the user's Clerk profile.
4
+ * Saves an optional phone number to the user's Clerk profile.
5
5
  * Called from /activate/[orderId] — never required, always skippable.
6
6
  *
7
7
  * Body: { phone: string }
@@ -2,25 +2,34 @@
2
2
  /**
3
3
  * CheckoutClient
4
4
  *
5
- * MANGO-8/9: Purchase-first flow.
5
+ * Purchase-first flow.
6
6
  *
7
- * OLD behaviour: unauthenticated user → redirected to /sign-in (blocking).
8
- * NEW behaviour:
9
- * - Unauthenticated → POST /api/checkout/guest → Stripe Checkout directly.
10
- * - Authenticated → use server-rendered session URL → Stripe Checkout.
7
+ * Unauthenticated → POST /api/checkout/guest → pay.
8
+ * Authenticated → server-rendered session URL → pay.
11
9
  *
12
- * After Stripe payment success, the user lands on /checkout/success where they
13
- * are prompted to create an account (email + password, no phone) or sign in.
14
- * Then they are redirected to /activate/[orderId] for optional WhatsApp/phone setup.
10
+ * "Pay" is one of two things. With a Stripe publishable key configured the
11
+ * payment form is embedded in this page, and on completion we navigate to
12
+ * /checkout/success ourselves — Stripe's embedded form otherwise leaves the
13
+ * customer on a "payment received" panel with nowhere to go. Without a
14
+ * publishable key the browser goes to Stripe's hosted page and comes back.
15
+ * Both take money; the embedded one loses fewer people on the way.
16
+ *
17
+ * After payment the user lands on /checkout/success, where they create an
18
+ * account (email + password, no phone) or sign in, and are then sent to
19
+ * /activate/[orderId].
15
20
  *
16
21
  * Every string on this page is read by a customer. It names no environment
17
22
  * variable and gives no configuration instructions — an operator's deployment
18
23
  * problem is not something a shopper can act on, and printing it makes a store
19
24
  * look broken rather than busy.
20
25
  */
21
- import { useEffect, useRef, useState } from "react";
26
+ import { useCallback, useEffect, useRef, useState } from "react";
22
27
  import { useAuth } from "@clerk/nextjs";
23
28
  import { useRouter } from "next/navigation";
29
+ import {
30
+ EmbeddedStripeCheckout,
31
+ canEmbedStripeCheckout,
32
+ } from "@/components/checkout/EmbeddedStripeCheckout";
24
33
  import { conversionEvents } from "@/lib/conversion-events";
25
34
  import { normalizeChannel } from "@/vendor/carrier/catalog-guard";
26
35
  import type { SkuTemplate } from "@/vendor/carrier/types";
@@ -46,13 +55,89 @@ function readChannel(): string {
46
55
  return normalizeChannel(params.get("channel") ?? params.get("utm_source"));
47
56
  }
48
57
 
58
+ /** Internal signal: the embedded path failed but a hosted one is still worth trying. */
59
+ class EmbedUnavailable extends Error {
60
+ constructor() {
61
+ super("embed_unavailable");
62
+ this.name = "EmbedUnavailable";
63
+ }
64
+ }
65
+
49
66
  export function CheckoutClient({ plan, session }: Props) {
50
67
  const { isSignedIn, isLoaded } = useAuth();
51
68
  const router = useRouter();
52
69
  const [checkoutError, setCheckoutError] = useState<string | null>(null);
53
70
  const [loading, setLoading] = useState(false);
71
+ const [clientSecret, setClientSecret] = useState<string | null>(null);
72
+ const [sessionId, setSessionId] = useState<string | null>(null);
54
73
  const guestCheckoutPlanIdRef = useRef<string | null>(null);
55
74
  const authRefreshAttemptedRef = useRef(false);
75
+ /**
76
+ * Set once the embedded attempt has been given up on. The retry is hosted,
77
+ * and the flag stops a failing embed from looping through this component.
78
+ */
79
+ const embedAbandonedRef = useRef(false);
80
+
81
+ const startGuestCheckout = useCallback(
82
+ (wantEmbed: boolean) => {
83
+ // Named inner function so the hosted retry can call itself without the
84
+ // callback depending on its own identity.
85
+ const run = (embed: boolean): void => {
86
+ setLoading(true);
87
+ fetch("/api/checkout/guest", {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({
91
+ templateId: plan.id,
92
+ channel: readChannel(),
93
+ ...(embed ? { uiMode: "embedded" } : {}),
94
+ }),
95
+ })
96
+ .then(async (res) => {
97
+ if (!res.ok) {
98
+ // The route returns customer-safe copy for every refusal it knows
99
+ // about. Anything else (a proxy error page, say) gets the generic
100
+ // line rather than whatever text happened to come back.
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);
104
+ }
105
+ return res.json() as Promise<{
106
+ url?: string;
107
+ clientSecret?: string;
108
+ sessionId?: string;
109
+ }>;
110
+ })
111
+ .then((data) => {
112
+ if (embed && data.clientSecret && data.sessionId) {
113
+ setClientSecret(data.clientSecret);
114
+ setSessionId(data.sessionId);
115
+ setLoading(false);
116
+ return;
117
+ }
118
+ if (data.url) {
119
+ window.location.href = data.url;
120
+ return;
121
+ }
122
+ throw new Error(GENERIC_CHECKOUT_ERROR);
123
+ })
124
+ .catch((err: unknown) => {
125
+ if (err instanceof EmbedUnavailable && !embedAbandonedRef.current) {
126
+ // The embed could not be created. A hosted session still can be,
127
+ // and a customer who came here to pay should be allowed to.
128
+ embedAbandonedRef.current = true;
129
+ run(false);
130
+ return;
131
+ }
132
+ guestCheckoutPlanIdRef.current = null;
133
+ setCheckoutError(err instanceof Error ? err.message : GENERIC_CHECKOUT_ERROR);
134
+ setLoading(false);
135
+ });
136
+ };
137
+ run(wantEmbed);
138
+ },
139
+ [plan.id],
140
+ );
56
141
 
57
142
  useEffect(() => {
58
143
  if (!isLoaded) return;
@@ -74,44 +159,44 @@ export function CheckoutClient({ plan, session }: Props) {
74
159
  return;
75
160
  }
76
161
 
77
- // Guest path (MANGO-8): call the guest checkout API — no auth required.
162
+ // Guest path: no auth required.
78
163
  if (guestCheckoutPlanIdRef.current === plan.id) return;
79
164
  guestCheckoutPlanIdRef.current = plan.id;
165
+ startGuestCheckout(canEmbedStripeCheckout);
166
+ }, [isLoaded, isSignedIn, plan, session, router, startGuestCheckout]);
80
167
 
81
- setLoading(true);
82
- fetch("/api/checkout/guest", {
83
- method: "POST",
84
- headers: { "Content-Type": "application/json" },
85
- body: JSON.stringify({ templateId: plan.id, channel: readChannel() }),
86
- })
87
- .then(async (res) => {
88
- if (!res.ok) {
89
- // The route returns customer-safe copy for every refusal it knows
90
- // about. Anything else (a proxy error page, say) gets the generic
91
- // line rather than whatever text happened to come back.
92
- const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
93
- throw new Error(err.code && err.error ? err.error : GENERIC_CHECKOUT_ERROR);
94
- }
95
- return res.json() as Promise<{ url: string }>;
96
- })
97
- .then(({ url }) => {
98
- window.location.href = url;
99
- })
100
- .catch((err: unknown) => {
101
- guestCheckoutPlanIdRef.current = null;
102
- setCheckoutError(err instanceof Error ? err.message : GENERIC_CHECKOUT_ERROR);
103
- setLoading(false);
104
- });
105
- }, [isLoaded, isSignedIn, plan, session, router]);
168
+ /**
169
+ * The payment cleared inside the iframe and no redirect was required.
170
+ *
171
+ * `replace`, not `push`: the back button must not return to a Checkout
172
+ * Session that has already been paid.
173
+ */
174
+ const handleComplete = useCallback(() => {
175
+ const params = new URLSearchParams({ templateId: plan.id });
176
+ if (sessionId) params.set("session_id", sessionId);
177
+ router.replace(`/checkout/success?${params.toString()}`);
178
+ }, [plan.id, router, sessionId]);
179
+
180
+ /** Stripe.js never loaded. Fall back to the page Stripe hosts itself. */
181
+ const handleEmbedUnavailable = useCallback(() => {
182
+ if (embedAbandonedRef.current) {
183
+ setCheckoutError(GENERIC_CHECKOUT_ERROR);
184
+ return;
185
+ }
186
+ embedAbandonedRef.current = true;
187
+ setClientSecret(null);
188
+ setSessionId(null);
189
+ startGuestCheckout(false);
190
+ }, [startGuestCheckout]);
106
191
 
107
192
  if (checkoutError) {
108
193
  return (
109
194
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
110
195
  <p className="text-sand-700 dark:text-sand-200">{checkoutError}</p>
111
- <p className="text-sm text-sand-400 mt-2">{plan.name}</p>
196
+ <p className="mt-2 text-sm text-sand-400">{plan.name}</p>
112
197
  <a
113
198
  href="/shop"
114
- className="mt-6 inline-block rounded-full border border-sand-300 dark:border-sand-700 px-6 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
199
+ className="mt-6 inline-block rounded-full border border-sand-300 px-6 py-2 text-sm text-sand-900 transition-colors hover:bg-white/10 dark:border-sand-700 dark:text-sand-50"
115
200
  >
116
201
  ← Back to shop
117
202
  </a>
@@ -119,13 +204,31 @@ export function CheckoutClient({ plan, session }: Props) {
119
204
  );
120
205
  }
121
206
 
207
+ if (clientSecret) {
208
+ return (
209
+ <div className="mx-auto max-w-2xl px-4 py-16">
210
+ <h1 className="text-2xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
211
+ Almost there.
212
+ </h1>
213
+ <p className="mt-2 mb-8 text-sm text-sand-500">
214
+ {plan.name} — your QR code is ready the moment this clears.
215
+ </p>
216
+ <EmbeddedStripeCheckout
217
+ clientSecret={clientSecret}
218
+ onComplete={handleComplete}
219
+ onUnavailable={handleEmbedUnavailable}
220
+ />
221
+ </div>
222
+ );
223
+ }
224
+
122
225
  return (
123
226
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
124
- <div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
227
+ <div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
125
228
  <p className="text-sand-600 dark:text-sand-400">
126
229
  {loading ? "Preparing your checkout…" : "Redirecting to payment…"}
127
230
  </p>
128
- <p className="text-sm text-sand-400 mt-2">{plan.name}</p>
231
+ <p className="mt-2 text-sm text-sand-400">{plan.name}</p>
129
232
  </div>
130
233
  );
131
234
  }
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * CheckoutSuccessClient
4
4
  *
5
- * MANGO-8/9: Shown after Stripe payment success.
5
+ * Shown after Stripe payment success.
6
6
  *
7
7
  * - If already signed in: redirect immediately to /activate/[orderId].
8
8
  * - If not signed in: show email + password sign-up form (no phone).
@@ -2,7 +2,7 @@ export const dynamic = "force-dynamic";
2
2
  /**
3
3
  * /checkout/success
4
4
  *
5
- * MANGO-8/9: Post-payment landing page.
5
+ * Post-payment landing page.
6
6
  *
7
7
  * The user arrives here after a successful Stripe Checkout payment.
8
8
  * Query params from Stripe: ?templateId=<id>&session_id=<stripe_session_id>
@@ -0,0 +1,293 @@
1
+ "use client";
2
+ /**
3
+ * Per-eSIM controls on the dashboard: how much is left, buy more, stop paying.
4
+ *
5
+ * The list on its own answered "did my purchase happen". These three answer
6
+ * the questions people actually come back for, and the third one is the
7
+ * reason: a plan that can only be cancelled by emailing support is a plan the
8
+ * customer cancels through their bank.
9
+ *
10
+ * Everything here loads lazily, one eSIM at a time, on expand. The dashboard
11
+ * already makes one fulfilment call per order to render the list; making three
12
+ * more per order up front would turn a slow page into an unusable one for the
13
+ * customer with six eSIMs, who is exactly the customer worth keeping.
14
+ */
15
+
16
+ import { useCallback, useState } from "react";
17
+ import {
18
+ daysUntil,
19
+ formatBytes,
20
+ formatDataAllowance,
21
+ formatPrice,
22
+ formatValidity,
23
+ } from "@/vendor/geo";
24
+ import type { EsimUsage, TopUpOption } from "@/vendor/carrier/types";
25
+
26
+ interface Props {
27
+ orderId: string;
28
+ /** Already cancelled upstream. The cancel control is not offered again. */
29
+ cancelled: boolean;
30
+ }
31
+
32
+ type Loadable<T> =
33
+ | { phase: "idle" }
34
+ | { phase: "loading" }
35
+ | { phase: "ready"; value: T }
36
+ | { phase: "error"; message: string };
37
+
38
+ const GENERIC_ERROR = "We couldn't reach your plan just now. Try again in a moment.";
39
+
40
+ async function readJson<T>(res: Response): Promise<T & { error?: string }> {
41
+ return (await res.json().catch(() => ({}))) as T & { error?: string };
42
+ }
43
+
44
+ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props) {
45
+ const [open, setOpen] = useState(false);
46
+ const [usage, setUsage] = useState<Loadable<EsimUsage>>({ phase: "idle" });
47
+ const [topUps, setTopUps] = useState<Loadable<TopUpOption[]>>({ phase: "idle" });
48
+ const [cancelled, setCancelled] = useState(initiallyCancelled);
49
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
50
+ const [cancelState, setCancelState] = useState<Loadable<true>>({ phase: "idle" });
51
+ const [buying, setBuying] = useState<string | null>(null);
52
+
53
+ const base = `/api/orders/${encodeURIComponent(orderId)}`;
54
+
55
+ const load = useCallback(async () => {
56
+ setUsage({ phase: "loading" });
57
+ setTopUps({ phase: "loading" });
58
+
59
+ const [usageRes, topUpRes] = await Promise.allSettled([
60
+ fetch(`${base}/usage`, { cache: "no-store" }),
61
+ fetch(`${base}/top-up-options`, { cache: "no-store" }),
62
+ ]);
63
+
64
+ if (usageRes.status === "fulfilled") {
65
+ const payload = await readJson<{ usage?: EsimUsage }>(usageRes.value);
66
+ setUsage(
67
+ usageRes.value.ok && payload.usage
68
+ ? { phase: "ready", value: payload.usage }
69
+ : { phase: "error", message: payload.error ?? GENERIC_ERROR },
70
+ );
71
+ } else {
72
+ setUsage({ phase: "error", message: GENERIC_ERROR });
73
+ }
74
+
75
+ if (topUpRes.status === "fulfilled") {
76
+ const payload = await readJson<{ options?: TopUpOption[] }>(topUpRes.value);
77
+ setTopUps(
78
+ topUpRes.value.ok && Array.isArray(payload.options)
79
+ ? { phase: "ready", value: payload.options }
80
+ : { phase: "error", message: payload.error ?? GENERIC_ERROR },
81
+ );
82
+ } else {
83
+ setTopUps({ phase: "error", message: GENERIC_ERROR });
84
+ }
85
+ }, [base]);
86
+
87
+ function toggle() {
88
+ const next = !open;
89
+ setOpen(next);
90
+ if (next && usage.phase === "idle") void load();
91
+ }
92
+
93
+ async function buyTopUp(option: TopUpOption) {
94
+ setBuying(option.id);
95
+ try {
96
+ const res = await fetch("/api/checkout/guest", {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({
100
+ templateId: option.id,
101
+ channel: "dashboard-topup",
102
+ topUpForOrderId: orderId,
103
+ }),
104
+ });
105
+ const payload = await readJson<{ url?: string }>(res);
106
+ if (!res.ok || !payload.url) throw new Error(payload.error ?? GENERIC_ERROR);
107
+ window.location.href = payload.url;
108
+ } catch (err) {
109
+ setBuying(null);
110
+ setTopUps({
111
+ phase: "error",
112
+ message: err instanceof Error ? err.message : GENERIC_ERROR,
113
+ });
114
+ }
115
+ }
116
+
117
+ async function doCancel() {
118
+ setCancelState({ phase: "loading" });
119
+ try {
120
+ const res = await fetch(`${base}/cancel`, { method: "POST" });
121
+ const payload = await readJson<{ cancelledAt?: string }>(res);
122
+ if (!res.ok) throw new Error(payload.error ?? GENERIC_ERROR);
123
+ setCancelled(true);
124
+ setConfirmingCancel(false);
125
+ setCancelState({ phase: "ready", value: true });
126
+ } catch (err) {
127
+ setCancelState({
128
+ phase: "error",
129
+ message: err instanceof Error ? err.message : GENERIC_ERROR,
130
+ });
131
+ }
132
+ }
133
+
134
+ return (
135
+ <div className="mt-4 border-t border-sand-200 pt-4 dark:border-sand-800">
136
+ <button
137
+ type="button"
138
+ onClick={toggle}
139
+ aria-expanded={open}
140
+ className="text-sm font-medium text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50"
141
+ >
142
+ {open ? "Hide plan details" : "Usage, top-up and cancel"}
143
+ </button>
144
+
145
+ {open && (
146
+ <div className="mt-4 grid gap-6">
147
+ <UsageMeter state={usage} />
148
+
149
+ <section>
150
+ <p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
151
+ Add more data
152
+ </p>
153
+ {topUps.phase === "loading" && <p className="text-sm text-sand-500">Checking…</p>}
154
+ {topUps.phase === "error" && (
155
+ <p className="text-sm text-sand-500">{topUps.message}</p>
156
+ )}
157
+ {topUps.phase === "ready" && topUps.value.length === 0 && (
158
+ <p className="text-sm text-sand-500">
159
+ This plan can&apos;t be topped up. Buy another when it runs out.
160
+ </p>
161
+ )}
162
+ {topUps.phase === "ready" && topUps.value.length > 0 && (
163
+ <ul className="grid gap-2">
164
+ {topUps.value.map((option) => (
165
+ <li
166
+ key={option.id}
167
+ className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sand-200 px-4 py-3 dark:border-sand-800"
168
+ >
169
+ <div>
170
+ <p className="text-sm font-medium text-sand-900 dark:text-sand-50">
171
+ {option.name}
172
+ </p>
173
+ <p className="text-xs text-sand-500">
174
+ {formatDataAllowance(option.dataGb)} · {formatValidity(option.validityDays)}
175
+ </p>
176
+ </div>
177
+ <button
178
+ type="button"
179
+ onClick={() => void buyTopUp(option)}
180
+ disabled={buying !== null}
181
+ className="rounded-full px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
182
+ style={{ background: "var(--brand-accent)" }}
183
+ >
184
+ {buying === option.id
185
+ ? "Opening checkout…"
186
+ : formatPrice(option.price_cents, option.currency)}
187
+ </button>
188
+ </li>
189
+ ))}
190
+ </ul>
191
+ )}
192
+ </section>
193
+
194
+ <section>
195
+ <p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
196
+ Cancel
197
+ </p>
198
+ {cancelled ? (
199
+ <p className="text-sm text-sand-500">
200
+ This plan is cancelled. Any data already on the eSIM stays usable until it
201
+ expires.
202
+ </p>
203
+ ) : confirmingCancel ? (
204
+ <div>
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.
208
+ </p>
209
+ <div className="flex flex-wrap gap-3">
210
+ <button
211
+ type="button"
212
+ onClick={() => void doCancel()}
213
+ disabled={cancelState.phase === "loading"}
214
+ className="rounded-full bg-red-600 px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
215
+ >
216
+ {cancelState.phase === "loading" ? "Cancelling…" : "Yes, cancel it"}
217
+ </button>
218
+ <button
219
+ type="button"
220
+ onClick={() => setConfirmingCancel(false)}
221
+ className="rounded-full border border-sand-300 px-5 py-2 text-sm text-sand-900 dark:border-sand-700 dark:text-sand-50"
222
+ >
223
+ Keep it
224
+ </button>
225
+ </div>
226
+ {cancelState.phase === "error" && (
227
+ <p className="mt-3 text-sm text-sand-500">{cancelState.message}</p>
228
+ )}
229
+ </div>
230
+ ) : (
231
+ <button
232
+ type="button"
233
+ onClick={() => setConfirmingCancel(true)}
234
+ className="text-sm font-medium text-sand-500 underline underline-offset-4 hover:text-sand-900 dark:hover:text-sand-50"
235
+ >
236
+ Cancel this plan
237
+ </button>
238
+ )}
239
+ </section>
240
+ </div>
241
+ )}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function UsageMeter({ state }: { state: Loadable<EsimUsage> }) {
247
+ if (state.phase === "loading") {
248
+ return <p className="text-sm text-sand-500">Reading your usage…</p>;
249
+ }
250
+ if (state.phase === "error") {
251
+ return <p className="text-sm text-sand-500">{state.message}</p>;
252
+ }
253
+ if (state.phase !== "ready") return null;
254
+
255
+ const { usedBytes, totalBytes, expiresAt } = state.value;
256
+ const left = daysUntil(expiresAt);
257
+ // Only drawn when the operator stated a ceiling. A bar with an invented
258
+ // denominator is a number the customer plans a trip around.
259
+ const pct =
260
+ typeof totalBytes === "number" && totalBytes > 0
261
+ ? Math.min(100, Math.round((usedBytes / totalBytes) * 100))
262
+ : null;
263
+
264
+ return (
265
+ <section>
266
+ <p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">Usage</p>
267
+ <p className="text-sm text-sand-900 dark:text-sand-50">
268
+ {formatBytes(usedBytes)} used
269
+ {typeof totalBytes === "number" ? ` of ${formatBytes(totalBytes)}` : ""}
270
+ </p>
271
+ {pct !== null && (
272
+ <div
273
+ role="progressbar"
274
+ aria-valuenow={pct}
275
+ aria-valuemin={0}
276
+ aria-valuemax={100}
277
+ aria-label="Data used"
278
+ className="mt-2 h-2 w-full overflow-hidden rounded-full bg-sand-200 dark:bg-sand-800"
279
+ >
280
+ <div
281
+ className="h-full rounded-full"
282
+ style={{ width: `${pct}%`, background: "var(--brand-accent)" }}
283
+ />
284
+ </div>
285
+ )}
286
+ {left !== null && (
287
+ <p className="mt-2 text-xs text-sand-500">
288
+ {left === 0 ? "Expires today" : left === 1 ? "1 day left" : `${left} days left`}
289
+ </p>
290
+ )}
291
+ </section>
292
+ );
293
+ }
@@ -14,6 +14,7 @@ import { redirect } from "next/navigation";
14
14
  import { listClaimedOrderIds } from "@/lib/checkout-order-claim";
15
15
  import { fetchOrder } from "@/vendor/carrier/client";
16
16
  import type { EsimOrder, OrderStatus } from "@/vendor/carrier/types";
17
+ import { EsimControls } from "./EsimControls";
17
18
 
18
19
  export const metadata = { title: "Dashboard" };
19
20
 
@@ -47,7 +48,11 @@ function OrderRow({ orderId, order }: { orderId: string; order: EsimOrder | null
47
48
  {order?.planName ?? order?.templateId ?? "eSIM order"}
48
49
  </p>
49
50
  <span className="text-xs text-sand-500">
50
- {order ? STATUS_LABEL[order.status] : "Status unavailable"}
51
+ {order
52
+ ? order.cancelledAt
53
+ ? "Cancelled"
54
+ : STATUS_LABEL[order.status]
55
+ : "Status unavailable"}
51
56
  </span>
52
57
  </div>
53
58
  <p className="mt-1 font-mono text-xs break-all text-sand-400">{orderId}</p>
@@ -63,6 +68,12 @@ function OrderRow({ orderId, order }: { orderId: string; order: EsimOrder | null
63
68
  >
64
69
  {order?.status === "ready" ? "View QR code" : "View order"} →
65
70
  </a>
71
+ {/* Only once there is an eSIM behind the row. Usage, top-up and cancel
72
+ all address a thing that exists; offering them over a pending order
73
+ invites three calls that can only answer "not yet". */}
74
+ {order?.status === "ready" && (
75
+ <EsimControls orderId={orderId} cancelled={Boolean(order.cancelledAt)} />
76
+ )}
66
77
  </li>
67
78
  );
68
79
  }