"use client"; /** * Per-eSIM controls on the dashboard: how much is left, buy more, stop paying. * * The list on its own answered "did my purchase happen". These three answer * the questions people actually come back for, and the third one is the * reason: a plan that can only be cancelled by emailing support is a plan the * customer cancels through their bank. * * Everything here loads lazily, one eSIM at a time, on expand. The dashboard * already makes one fulfilment call per order to render the list; making three * more per order up front would turn a slow page into an unusable one for the * customer with six eSIMs, who is exactly the customer worth keeping. */ import { useCallback, useState } from "react"; import { daysUntil, formatBytes, formatDataAllowance, formatPrice, formatValidity, } from "@/vendor/geo"; import type { EsimUsage, TopUpOption } from "@/vendor/carrier/types"; interface Props { orderId: string; /** Already cancelled upstream. The cancel control is not offered again. */ cancelled: boolean; } type Loadable = | { phase: "idle" } | { phase: "loading" } | { phase: "ready"; value: T } | { phase: "error"; message: string }; const GENERIC_ERROR = "We couldn't reach your plan just now. Try again in a moment."; async function readJson(res: Response): Promise { return (await res.json().catch(() => ({}))) as T & { error?: string }; } export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props) { const [open, setOpen] = useState(false); const [usage, setUsage] = useState>({ phase: "idle" }); const [topUps, setTopUps] = useState>({ phase: "idle" }); const [cancelled, setCancelled] = useState(initiallyCancelled); const [confirmingCancel, setConfirmingCancel] = useState(false); const [cancelState, setCancelState] = useState>({ phase: "idle" }); const [buying, setBuying] = useState(null); const base = `/api/orders/${encodeURIComponent(orderId)}`; const load = useCallback(async () => { setUsage({ phase: "loading" }); setTopUps({ phase: "loading" }); const [usageRes, topUpRes] = await Promise.allSettled([ fetch(`${base}/usage`, { cache: "no-store" }), fetch(`${base}/top-up-options`, { cache: "no-store" }), ]); if (usageRes.status === "fulfilled") { const payload = await readJson<{ usage?: EsimUsage }>(usageRes.value); setUsage( usageRes.value.ok && payload.usage ? { phase: "ready", value: payload.usage } : { phase: "error", message: payload.error ?? GENERIC_ERROR }, ); } else { setUsage({ phase: "error", message: GENERIC_ERROR }); } if (topUpRes.status === "fulfilled") { const payload = await readJson<{ options?: TopUpOption[] }>(topUpRes.value); setTopUps( topUpRes.value.ok && Array.isArray(payload.options) ? { phase: "ready", value: payload.options } : { phase: "error", message: payload.error ?? GENERIC_ERROR }, ); } else { setTopUps({ phase: "error", message: GENERIC_ERROR }); } }, [base]); function toggle() { const next = !open; setOpen(next); if (next && usage.phase === "idle") void load(); } async function buyTopUp(option: TopUpOption) { setBuying(option.id); try { const res = await fetch("/api/checkout/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ templateId: option.id, channel: "dashboard-topup", topUpForOrderId: orderId, }), }); const payload = await readJson<{ url?: string }>(res); if (!res.ok || !payload.url) throw new Error(payload.error ?? GENERIC_ERROR); window.location.href = payload.url; } catch (err) { setBuying(null); setTopUps({ phase: "error", message: err instanceof Error ? err.message : GENERIC_ERROR, }); } } async function doCancel() { setCancelState({ phase: "loading" }); try { const res = await fetch(`${base}/cancel`, { method: "POST" }); const payload = await readJson<{ cancelledAt?: string }>(res); if (!res.ok) throw new Error(payload.error ?? GENERIC_ERROR); setCancelled(true); setConfirmingCancel(false); setCancelState({ phase: "ready", value: true }); } catch (err) { setCancelState({ phase: "error", message: err instanceof Error ? err.message : GENERIC_ERROR, }); } } return (
{open && (

Add more data

{topUps.phase === "loading" &&

Checking…

} {topUps.phase === "error" && (

{topUps.message}

)} {topUps.phase === "ready" && topUps.value.length === 0 && (

This plan can't be topped up. Buy another when it runs out.

)} {topUps.phase === "ready" && topUps.value.length > 0 && (
    {topUps.value.map((option) => (
  • {option.name}

    {formatDataAllowance(option.dataGb)} · {formatValidity(option.validityDays)}

  • ))}
)}

Cancel

{cancelled ? (

This plan is cancelled. Any data already on the eSIM stays usable until it expires.

) : confirmingCancel ? (

Cancel this plan? It stops renewing. Data already paid for stays on the eSIM until it expires, and this cannot be undone from here.

{cancelState.phase === "error" && (

{cancelState.message}

)}
) : ( )}
)}
); } function UsageMeter({ state }: { state: Loadable }) { if (state.phase === "loading") { return

Reading your usage…

; } if (state.phase === "error") { return

{state.message}

; } if (state.phase !== "ready") return null; const { usedBytes, totalBytes, expiresAt } = state.value; const left = daysUntil(expiresAt); // Only drawn when the operator stated a ceiling. A bar with an invented // denominator is a number the customer plans a trip around. const pct = typeof totalBytes === "number" && totalBytes > 0 ? Math.min(100, Math.round((usedBytes / totalBytes) * 100)) : null; return (

Usage

{formatBytes(usedBytes)} used {typeof totalBytes === "number" ? ` of ${formatBytes(totalBytes)}` : ""}

{pct !== null && (
)} {left !== null && (

{left === 0 ? "Expires today" : left === 1 ? "1 day left" : `${left} days left`}

)}
); }