@carrierllc/mcp 0.11.16 → 0.12.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.
- package/dist/{chunk-Y7D6FHHT.js → chunk-J52VP7E7.js} +14 -2
- package/dist/chunk-J52VP7E7.js.map +1 -0
- package/dist/cli.js +22 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.js +2 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
- package/plugin/carrier/skills/storefront/SKILL.md +2 -2
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +145 -14
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
- package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
- package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
- package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
- package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
- package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
- package/templates/storefront/src/app/dashboard/page.tsx +12 -1
- package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
- package/templates/storefront/src/brand.config.ts +19 -0
- package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
- package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
- package/templates/storefront/src/lib/checkout-order-claim.ts +68 -4
- package/templates/storefront/src/lib/order-route-guard.ts +85 -0
- package/templates/storefront/src/middleware.ts +1 -1
- package/templates/storefront/src/vendor/carrier/client.ts +86 -1
- package/templates/storefront/src/vendor/carrier/types.ts +34 -0
- package/templates/storefront/src/vendor/geo/index.ts +45 -0
- package/templates/storefront/src/vendor/ui/brand.ts +8 -1
- package/dist/chunk-Y7D6FHHT.js.map +0 -1
|
@@ -2,25 +2,34 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* CheckoutClient
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Purchase-first flow.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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'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
|
|
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
|
}
|