@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.
Files changed (68) hide show
  1. package/dist/{chunk-6OAZJBLA.js → chunk-4XUAUQ3U.js} +67 -7
  2. package/dist/chunk-4XUAUQ3U.js.map +1 -0
  3. package/dist/cli.js +55 -9
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +66 -15
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/templates/storefront/CLERK.md +23 -0
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
  11. package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
  12. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
  13. package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
  14. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
  15. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
  16. package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
  17. package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
  18. package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
  19. package/templates/storefront/src/app/dashboard/page.tsx +43 -23
  20. package/templates/storefront/src/app/globals.css +44 -5
  21. package/templates/storefront/src/app/help/page.tsx +2 -2
  22. package/templates/storefront/src/app/layout.tsx +5 -3
  23. package/templates/storefront/src/app/page.tsx +27 -7
  24. package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
  25. package/templates/storefront/src/app/shop/page.tsx +7 -2
  26. package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
  27. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
  28. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
  29. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
  30. package/templates/storefront/src/brand.config.ts +16 -0
  31. package/templates/storefront/src/components/Providers.tsx +20 -1
  32. package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
  33. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
  34. package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
  35. package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
  36. package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
  37. package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
  38. package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
  39. package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
  40. package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
  41. package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
  42. package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
  43. package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
  44. package/templates/storefront/src/components/theme/brand-style.ts +88 -0
  45. package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
  46. package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
  47. package/templates/storefront/src/lib/buyer-error.ts +32 -0
  48. package/templates/storefront/src/lib/buyer-session.ts +41 -0
  49. package/templates/storefront/src/lib/catalog-countries.ts +179 -0
  50. package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
  51. package/templates/storefront/src/lib/checkout-copy.ts +89 -0
  52. package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
  53. package/templates/storefront/src/lib/checkout-return.ts +18 -0
  54. package/templates/storefront/src/lib/clerk-instance.ts +89 -0
  55. package/templates/storefront/src/lib/order-reference.ts +15 -0
  56. package/templates/storefront/src/lib/request-origin.ts +28 -0
  57. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
  58. package/templates/storefront/src/lib/tap-target.ts +17 -0
  59. package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
  60. package/templates/storefront/src/middleware.ts +11 -0
  61. package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
  62. package/templates/storefront/src/vendor/carrier/client.ts +22 -4
  63. package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
  64. package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
  65. package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
  66. package/templates/storefront/src/vendor/carrier/types.ts +22 -0
  67. package/templates/storefront/src/vendor/geo/index.ts +15 -4
  68. package/dist/chunk-6OAZJBLA.js.map +0 -1
@@ -4,37 +4,157 @@ export const dynamic = "force-dynamic";
4
4
  *
5
5
  * Post-payment landing page.
6
6
  *
7
- * The user arrives here after a successful Stripe Checkout payment.
8
- * Query params from Stripe: ?templateId=<id>&session_id=<stripe_session_id>
7
+ * Query params: ?templateId=<id>&session_id=<session id>
9
8
  *
10
9
  * Flow:
11
- * 1. Show payment success confirmation.
12
- * 2. If already signed in → redirect to /activate/[orderId] immediately.
13
- * 3. If not signed in → show email + password sign-up form (no phone).
14
- * 4. After sign-up/sign-in → redirect to /activate/[orderId].
10
+ * 1. A mock session id is an error. No payment was taken.
11
+ * 2. A completed session that is still unpaid is processing.
12
+ * 3. An open or expired unpaid session did not go through.
13
+ * 4. A failed read is unavailable.
14
+ * 5. A session for another shop is refused here. No email is shown.
15
+ * 6. A settled session for this shop shows a confirmation before any account step.
15
16
  *
16
- * The orderId is derived from the Stripe session_id (or templateId as fallback)
17
- * so the activate page can look up the order.
17
+ * The orderId is the session id so the activate page can look up the order.
18
18
  */
19
- import { redirect } from "next/navigation";
20
- import { Suspense } from "react";
19
+ import { cache, Suspense, type ReactNode } from "react";
20
+ import brand from "@/brand.config";
21
+ import { confirmationDocumentTitle, shortOrderReference } from "@/lib/checkout-copy";
22
+ import { mockSessionProblem } from "@/lib/checkout-return";
21
23
  import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
22
24
  import { CheckoutSuccessClient } from "./CheckoutSuccessClient";
23
-
24
- export const metadata = { title: "Payment successful" };
25
+ import { FocusHeading } from "./FocusHeading";
25
26
 
26
27
  interface Props {
27
28
  searchParams: Promise<{ session_id?: string; templateId?: string }>;
28
29
  }
29
30
 
31
+ const filledLinkClass =
32
+ "mt-8 inline-flex min-h-11 items-center justify-center rounded-full px-6 text-base font-semibold";
33
+ const supportLinkClass =
34
+ "mt-4 inline-flex min-h-11 items-center justify-center text-base text-[var(--brand-accent-text)]";
35
+
36
+ const PROCESSING_STATUS =
37
+ "You don't need to pay again. Refresh in a minute to check again. Use Check again any time.";
38
+
39
+ const verifyForPage = cache(verifyCheckoutSession);
40
+
41
+ export async function generateMetadata({ searchParams }: Props): Promise<{ title: string }> {
42
+ const params = await searchParams;
43
+ if (mockSessionProblem(params.session_id)) return { title: confirmationDocumentTitle("mock") };
44
+ const verification = await verifyForPage(params.session_id ?? "", params.templateId ?? "");
45
+ return { title: confirmationDocumentTitle(verification) };
46
+ }
47
+
48
+ function retryHref(sessionId: string, templateId: string): string {
49
+ const params = new URLSearchParams({ session_id: sessionId, templateId });
50
+ return `/checkout/success?${params.toString()}`;
51
+ }
52
+
53
+ function FilledLink({ href, children }: { href: string; children: ReactNode }) {
54
+ return (
55
+ <a
56
+ href={href}
57
+ className={filledLinkClass}
58
+ style={{ background: "var(--brand-accent)", color: "var(--brand-on-accent)" }}
59
+ >
60
+ {children}
61
+ </a>
62
+ );
63
+ }
64
+
65
+ function SupportLink() {
66
+ return (
67
+ <a href={brand.supportUrl} className={supportLinkClass}>
68
+ Contact support
69
+ </a>
70
+ );
71
+ }
72
+
73
+ function Outcome({
74
+ title,
75
+ status,
76
+ alert,
77
+ action,
78
+ }: {
79
+ title: string;
80
+ status?: string;
81
+ alert?: string;
82
+ action?: ReactNode;
83
+ }) {
84
+ return (
85
+ <main className="mx-auto max-w-lg px-4 py-24 text-center">
86
+ <FocusHeading>{title}</FocusHeading>
87
+ {status ? (
88
+ <p role="status" className="mt-3 text-base text-sand-700 dark:text-sand-200">
89
+ {status}
90
+ </p>
91
+ ) : null}
92
+ {alert ? (
93
+ <p role="alert" className="mt-3 text-base text-sand-700 dark:text-sand-200">
94
+ {alert}
95
+ </p>
96
+ ) : null}
97
+ {action}
98
+ <SupportLink />
99
+ </main>
100
+ );
101
+ }
102
+
30
103
  export default async function CheckoutSuccessPage({ searchParams }: Props) {
31
104
  const params = await searchParams;
32
105
  const sessionId = params.session_id ?? "";
33
106
  const templateId = params.templateId ?? "";
107
+ const problem = mockSessionProblem(sessionId);
108
+ if (problem) {
109
+ return (
110
+ <Outcome
111
+ title={problem.title}
112
+ alert={problem.message}
113
+ action={<FilledLink href="/shop">Back to plans</FilledLink>}
114
+ />
115
+ );
116
+ }
34
117
 
35
- const verification = await verifyCheckoutSession(sessionId, templateId);
118
+ const verification = await verifyForPage(sessionId, templateId);
36
119
  if (!verification.ok) {
37
- redirect("/shop");
120
+ switch (verification.status) {
121
+ case "processing":
122
+ return (
123
+ <Outcome
124
+ title="You don't need to pay again"
125
+ status={PROCESSING_STATUS}
126
+ action={<FilledLink href={retryHref(sessionId, templateId)}>Check again</FilledLink>}
127
+ />
128
+ );
129
+ case "unpaid":
130
+ return (
131
+ <Outcome
132
+ title="Payment did not go through"
133
+ alert="No eSIM was issued."
134
+ action={<FilledLink href="/shop">Back to plans</FilledLink>}
135
+ />
136
+ );
137
+ case "unavailable":
138
+ return (
139
+ <Outcome
140
+ title="We couldn't confirm your payment yet"
141
+ status={`If you paid, you don't need to pay again. Check again in a minute, or contact support with reference ${shortOrderReference(sessionId)}.`}
142
+ action={<FilledLink href={retryHref(sessionId, templateId)}>Check again</FilledLink>}
143
+ />
144
+ );
145
+ case "rejected":
146
+ return (
147
+ <Outcome
148
+ title="This payment could not be confirmed"
149
+ alert="This checkout does not match this shop, so nothing was issued."
150
+ action={<FilledLink href="/shop">Back to plans</FilledLink>}
151
+ />
152
+ );
153
+ default: {
154
+ const unhandled: never = verification.status;
155
+ return unhandled;
156
+ }
157
+ }
38
158
  }
39
159
 
40
160
  return (
@@ -43,7 +163,7 @@ export default async function CheckoutSuccessPage({ searchParams }: Props) {
43
163
  <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
44
164
  <div className="text-center space-y-3">
45
165
  <div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
46
- <p className="text-sm text-sand-600 dark:text-sand-400">Loading…</p>
166
+ <p className="text-base text-sand-700 dark:text-sand-200">Loading…</p>
47
167
  </div>
48
168
  </div>
49
169
  }
@@ -53,6 +173,7 @@ export default async function CheckoutSuccessPage({ searchParams }: Props) {
53
173
  sessionId={sessionId}
54
174
  templateId={templateId}
55
175
  purchaserEmail={verification.purchaserEmail}
176
+ supportHref={brand.supportUrl}
56
177
  />
57
178
  </Suspense>
58
179
  );
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { useCallback, useState } from "react";
17
+ import { buyerFacingError } from "@/lib/buyer-error";
17
18
  import {
18
19
  daysUntil,
19
20
  formatBytes,
@@ -35,10 +36,12 @@ type Loadable<T> =
35
36
  | { phase: "ready"; value: T }
36
37
  | { phase: "error"; message: string };
37
38
 
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 };
39
+ async function readJson<T>(res: Response): Promise<T> {
40
+ try {
41
+ return (await res.json()) as T;
42
+ } catch {
43
+ return {} as T;
44
+ }
42
45
  }
43
46
 
44
47
  export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props) {
@@ -66,10 +69,10 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
66
69
  setUsage(
67
70
  usageRes.value.ok && payload.usage
68
71
  ? { phase: "ready", value: payload.usage }
69
- : { phase: "error", message: payload.error ?? GENERIC_ERROR },
72
+ : { phase: "error", message: buyerFacingError("plan", payload) },
70
73
  );
71
74
  } else {
72
- setUsage({ phase: "error", message: GENERIC_ERROR });
75
+ setUsage({ phase: "error", message: buyerFacingError("plan") });
73
76
  }
74
77
 
75
78
  if (topUpRes.status === "fulfilled") {
@@ -77,10 +80,10 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
77
80
  setTopUps(
78
81
  topUpRes.value.ok && Array.isArray(payload.options)
79
82
  ? { phase: "ready", value: payload.options }
80
- : { phase: "error", message: payload.error ?? GENERIC_ERROR },
83
+ : { phase: "error", message: buyerFacingError("plan", payload) },
81
84
  );
82
85
  } else {
83
- setTopUps({ phase: "error", message: GENERIC_ERROR });
86
+ setTopUps({ phase: "error", message: buyerFacingError("plan") });
84
87
  }
85
88
  }, [base]);
86
89
 
@@ -103,14 +106,11 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
103
106
  }),
104
107
  });
105
108
  const payload = await readJson<{ url?: string }>(res);
106
- if (!res.ok || !payload.url) throw new Error(payload.error ?? GENERIC_ERROR);
109
+ if (!res.ok || !payload.url) throw new Error("plan");
107
110
  window.location.assign(payload.url);
108
111
  } catch (err) {
109
112
  setBuying(null);
110
- setTopUps({
111
- phase: "error",
112
- message: err instanceof Error ? err.message : GENERIC_ERROR,
113
- });
113
+ setTopUps({ phase: "error", message: buyerFacingError("plan", err) });
114
114
  }
115
115
  }
116
116
 
@@ -119,15 +119,12 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
119
119
  try {
120
120
  const res = await fetch(`${base}/cancel`, { method: "POST" });
121
121
  const payload = await readJson<{ cancelledAt?: string }>(res);
122
- if (!res.ok) throw new Error(payload.error ?? GENERIC_ERROR);
122
+ if (!res.ok) throw new Error("plan");
123
123
  setCancelled(true);
124
124
  setConfirmingCancel(false);
125
125
  setCancelState({ phase: "ready", value: true });
126
126
  } catch (err) {
127
- setCancelState({
128
- phase: "error",
129
- message: err instanceof Error ? err.message : GENERIC_ERROR,
130
- });
127
+ setCancelState({ phase: "error", message: buyerFacingError("plan", err) });
131
128
  }
132
129
  }
133
130
 
@@ -137,7 +134,7 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
137
134
  type="button"
138
135
  onClick={toggle}
139
136
  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"
137
+ className="inline-flex min-h-11 items-center text-base font-medium text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50"
141
138
  >
142
139
  {open ? "Hide plan details" : "Usage, top-up and cancel"}
143
140
  </button>
@@ -178,8 +175,7 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
178
175
  type="button"
179
176
  onClick={() => void buyTopUp(option)}
180
177
  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)" }}
178
+ className="inline-flex min-h-11 items-center rounded-full bg-[var(--brand-accent)] px-4 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90 disabled:opacity-50"
183
179
  >
184
180
  {buying === option.id
185
181
  ? "Opening checkout…"
@@ -211,14 +207,14 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
211
207
  type="button"
212
208
  onClick={() => void doCancel()}
213
209
  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"
210
+ className="inline-flex min-h-11 items-center rounded-full bg-red-600 px-5 text-base font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
215
211
  >
216
212
  {cancelState.phase === "loading" ? "Cancelling…" : "Yes, cancel it"}
217
213
  </button>
218
214
  <button
219
215
  type="button"
220
216
  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"
217
+ className="inline-flex min-h-11 items-center rounded-full border border-sand-300 px-5 text-base text-sand-900 dark:border-sand-700 dark:text-sand-50"
222
218
  >
223
219
  Keep it
224
220
  </button>
@@ -231,7 +227,7 @@ export function EsimControls({ orderId, cancelled: initiallyCancelled }: Props)
231
227
  <button
232
228
  type="button"
233
229
  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"
230
+ className="inline-flex min-h-11 items-center text-base font-medium text-sand-700 underline underline-offset-4 hover:text-sand-900 dark:text-sand-200 dark:hover:text-sand-50"
235
231
  >
236
232
  Cancel this plan
237
233
  </button>
@@ -9,31 +9,50 @@ export const dynamic = "force-dynamic";
9
9
  * outcomes, all of them stated rather than implied: no orders, orders we could
10
10
  * read, and orders we could not read.
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 { 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 { shortOrderReference, supportOrderHref } from "@/lib/order-reference";
17
18
  import { EsimControls } from "./EsimControls";
19
+ import brand from "@/brand.config";
18
20
 
19
21
  export const metadata = { title: "Dashboard" };
20
22
 
23
+ const PAID_DETAIL = "Getting your eSIM ready. You don't need to pay again.";
24
+
21
25
  const STATUS_LABEL: Record<OrderStatus, string> = {
22
26
  pending: "Payment received",
23
27
  provisioning: "Setting up",
24
- ready: "Active",
28
+ ready: "Ready to install",
25
29
  failed: "Needs attention",
26
30
  };
27
31
 
32
+ function orderState(order: EsimOrder | null): { label: string; detail: string | null } {
33
+ if (!order) return { label: "Payment received", detail: PAID_DETAIL };
34
+ if (order.cancelledAt) return { label: "Cancelled", detail: null };
35
+ switch (order.status) {
36
+ case "pending":
37
+ return { label: STATUS_LABEL.pending, detail: PAID_DETAIL };
38
+ case "provisioning":
39
+ return { label: STATUS_LABEL.provisioning, detail: PAID_DETAIL };
40
+ case "ready":
41
+ return { label: STATUS_LABEL.ready, detail: null };
42
+ case "failed":
43
+ return { label: STATUS_LABEL.failed, detail: null };
44
+ default: {
45
+ const unknown: never = order.status;
46
+ return unknown;
47
+ }
48
+ }
49
+ }
50
+
28
51
  function EmptyState() {
29
52
  return (
30
53
  <div className="rounded-2xl border border-sand-200 bg-white p-12 text-center dark:border-sand-800 dark:bg-sand-900">
31
- <p className="text-sand-500">No active eSIMs yet.</p>
32
- <a
33
- href="/shop"
34
- className="mt-4 inline-block rounded-full px-6 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
35
- style={{ background: "var(--brand-accent)" }}
36
- >
54
+ <p className="text-sand-700 dark:text-sand-200">No active eSIMs yet.</p>
55
+ <a href="/shop" className="mt-4 inline-flex min-h-11 items-center justify-center rounded-full bg-[var(--brand-accent)] px-6 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90">
37
56
  Browse plans
38
57
  </a>
39
58
  </div>
@@ -41,30 +60,26 @@ function EmptyState() {
41
60
  }
42
61
 
43
62
  function OrderRow({ orderId, order }: { orderId: string; order: EsimOrder | null }) {
63
+ const state = orderState(order);
44
64
  return (
45
65
  <li className="rounded-2xl border border-sand-200 bg-white p-6 dark:border-sand-800 dark:bg-sand-900">
46
66
  <div className="flex flex-wrap items-baseline justify-between gap-2">
47
67
  <p className="font-medium text-sand-900 dark:text-sand-50">
48
- {order?.planName ?? order?.templateId ?? "eSIM order"}
68
+ {order?.planName ?? "eSIM order"}
49
69
  </p>
50
- <span className="text-xs text-sand-500">
51
- {order
52
- ? order.cancelledAt
53
- ? "Cancelled"
54
- : STATUS_LABEL[order.status]
55
- : "Status unavailable"}
56
- </span>
70
+ <span className="text-xs text-sand-700 dark:text-sand-200">{state.label}</span>
57
71
  </div>
58
- <p className="mt-1 font-mono text-xs break-all text-sand-400">{orderId}</p>
59
- {order?.iccid && <p className="mt-1 text-xs text-sand-500">ICCID {order.iccid}</p>}
72
+ {state.detail ? <p className="mt-2 text-sm text-sand-700 dark:text-sand-200">{state.detail}</p> : null}
73
+ <p className="mt-1 text-xs text-sand-600">{shortOrderReference(orderId)}</p>
74
+ {order?.iccid && <p className="mt-1 text-xs text-sand-700 dark:text-sand-200">ICCID {order.iccid}</p>}
60
75
  {!order && (
61
- <p className="mt-2 text-xs text-sand-500">
76
+ <p className="mt-2 text-xs text-sand-700 dark:text-sand-200">
62
77
  We couldn&apos;t reach this order just now. Your purchase is safe — try again shortly.
63
78
  </p>
64
79
  )}
65
80
  <a
66
81
  href={`/activate/${encodeURIComponent(orderId)}`}
67
- className="mt-4 inline-block text-sm font-semibold text-[var(--brand-accent)] hover:underline"
82
+ className="mt-4 inline-flex min-h-11 items-center text-base font-semibold text-[var(--brand-accent-text)] hover:underline"
68
83
  >
69
84
  {order?.status === "ready" ? "View QR code" : "View order"} →
70
85
  </a>
@@ -79,7 +94,7 @@ function OrderRow({ orderId, order }: { orderId: string; order: EsimOrder | null
79
94
  }
80
95
 
81
96
  export default async function DashboardPage() {
82
- const { userId } = await auth();
97
+ const userId = await buyerUserId();
83
98
  if (!userId) redirect("/sign-in?redirect_url=/dashboard");
84
99
 
85
100
  const orderIds = await listClaimedOrderIds(userId);
@@ -89,7 +104,9 @@ export default async function DashboardPage() {
89
104
  return { orderId, order: lookup.ok ? lookup.order : null };
90
105
  }),
91
106
  );
92
- const unreadable = orders.filter((entry) => entry.order === null).length;
107
+ const missing = orders.filter((entry) => entry.order === null);
108
+ const unreadable = missing.length;
109
+ const supportSubject = missing.length === 1 ? missing[0]?.orderId ?? "orders" : "orders";
93
110
 
94
111
  return (
95
112
  <div className="mx-auto max-w-4xl px-4 py-16">
@@ -104,7 +121,10 @@ export default async function DashboardPage() {
104
121
  {unreadable > 0 && (
105
122
  <p className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-sand-700 dark:text-sand-200">
106
123
  We couldn&apos;t load {unreadable === orders.length ? "your orders" : "some of your orders"}{" "}
107
- right now. Nothing is lost — refresh in a minute, or contact us with the order number.
124
+ right now. Nothing is lost — refresh in a minute, or{" "}
125
+ <a className="inline-flex min-h-11 items-center font-medium text-[var(--brand-accent-text)]" href={supportOrderHref(brand.supportEmail, supportSubject)}>
126
+ email {brand.supportEmail}
127
+ </a>
108
128
  </p>
109
129
  )}
110
130
  <ul className="space-y-4">
@@ -37,10 +37,18 @@
37
37
  --brand-accent: #FF6B35;
38
38
  --brand-accent-dark: #D9461C;
39
39
  --brand-accent-light: #FFB088;
40
+ /* #111111 on #FF6B35 is 6.66:1. White on that fill is 2.84:1. */
41
+ --brand-on-accent: #111111;
42
+ /* #C2410C is 5.18:1 on white and 4.95:1 on #FAFAF7. */
43
+ --brand-accent-text: #C2410C;
44
+ /* #C2410C is 4.95:1 on the light page. Dark focus is set below. */
45
+ --brand-focus: #C2410C;
40
46
  }
41
47
 
42
48
  html[data-theme="dark"] {
43
49
  color-scheme: dark;
50
+ --brand-accent-text: #FF8A5C;
51
+ --brand-focus: #FF8A5C;
44
52
  }
45
53
 
46
54
  * { box-sizing: border-box; }
@@ -74,11 +82,6 @@
74
82
  background-color: color-mix(in srgb, var(--brand-accent) 28%, transparent);
75
83
  }
76
84
 
77
- :focus-visible {
78
- outline: 2px solid var(--brand-accent);
79
- outline-offset: 2px;
80
- }
81
-
82
85
  @media (prefers-reduced-motion: reduce) {
83
86
  *, *::before, *::after {
84
87
  animation-duration: 0.01ms !important;
@@ -87,3 +90,39 @@
87
90
  }
88
91
  }
89
92
  }
93
+
94
+ /* Unlayered so these beat Tailwind utilities.
95
+ Fields under 16px make iOS zoom. Tap targets under 44px fail the phone pass. */
96
+ html,
97
+ body {
98
+ max-width: 100%;
99
+ overflow-x: clip;
100
+ }
101
+
102
+ header :where(a, button),
103
+ footer :where(a, button),
104
+ #main-content :where(a, button) {
105
+ min-height: 2.75rem;
106
+ }
107
+
108
+ #main-content :where(input, select, textarea) {
109
+ font-size: 1rem;
110
+ min-height: 2.75rem;
111
+ }
112
+
113
+ /* Unlayered so the ring beats any utility, including outline-none.
114
+ Dark accent text is #FF8A5C. #C2410C is only 3.43:1 on #1A1812.
115
+ Dark focus is #FFB088 (8.18:1 on #2C291F). #C2410C is 2.81:1 on that sand.
116
+ sand-500 #78725F is 3.69:1 on #1A1812. #9C9480 is 5.88:1 there and 4.82:1 on #2C291F.
117
+ sand-600 #5C5644 is 2.64:1 on #0F0E0A. #A39A86 is 6.92:1 there. */
118
+ html[data-theme="dark"] {
119
+ --brand-accent-text: #FF8A5C;
120
+ --brand-focus: #FFB088;
121
+ --color-sand-500: #9C9480;
122
+ --color-sand-600: #A39A86;
123
+ }
124
+
125
+ :focus-visible {
126
+ outline: 2px solid var(--brand-focus);
127
+ outline-offset: 2px;
128
+ }
@@ -17,11 +17,11 @@ export default function HelpPage() {
17
17
  <HowItWorks />
18
18
  <div className="mx-auto max-w-3xl px-4 pt-10 text-sm text-sand-600 dark:text-sand-400">
19
19
  Need a human?{" "}
20
- <a href={`mailto:${brand.supportEmail}`} className="font-medium" style={{ color: "var(--brand-accent)" }}>
20
+ <a href={`mailto:${brand.supportEmail}`} className="font-medium" style={{ color: "var(--brand-accent-text)" }}>
21
21
  {brand.supportEmail}
22
22
  </a>
23
23
  {" · "}
24
- <a href="/contact" className="font-medium" style={{ color: "var(--brand-accent)" }}>
24
+ <a href="/contact" className="font-medium" style={{ color: "var(--brand-accent-text)" }}>
25
25
  All channels
26
26
  </a>
27
27
  </div>
@@ -9,8 +9,11 @@ import { BrandStyles } from "@/components/theme/BrandStyles";
9
9
  import { StorefrontNav } from "@/components/nav/StorefrontNav";
10
10
  import { StorefrontFooter } from "@/components/footer/StorefrontFooter";
11
11
  import brand from "@/brand.config";
12
+ import { applyClerkInstanceEnv } from "@/lib/clerk-instance";
12
13
  import "./globals.css";
13
14
 
15
+ applyClerkInstanceEnv();
16
+
14
17
  export const metadata: Metadata = {
15
18
  title: {
16
19
  default: `${brand.name} — ${brand.tagline}`,
@@ -46,12 +49,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
46
49
  <ThemeScript />
47
50
  <BrandStyles />
48
51
  </head>
49
- <body>
52
+ <body className="overflow-x-clip">
50
53
  <Providers>
51
54
  <a
52
55
  href="#main-content"
53
- className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-20 focus:z-[100] focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-white"
54
- style={{ background: "var(--brand-accent)" }}
56
+ className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-20 focus:z-[100] focus:inline-flex focus:min-h-11 focus:items-center focus:rounded-lg focus:bg-[var(--brand-accent)] focus:px-4 focus:text-base focus:font-semibold focus:text-[var(--brand-on-accent)]"
55
57
  >
56
58
  Skip to main content
57
59
  </a>
@@ -2,33 +2,53 @@ import { HeroSection } from "@/components/landing/HeroSection";
2
2
  import { HowItWorks } from "@/components/landing/HowItWorks";
3
3
  import { FinalCTA } from "@/components/landing/FinalCTA";
4
4
  import { FaqSection } from "@/components/faq/FaqSection";
5
+ import { CountrySearch } from "@/components/shop/CountrySearch";
5
6
  import { fetchTemplates } from "@/vendor/carrier/client";
7
+ import { TierPlanBoard } from "@/components/landing/TierPlanBoard";
6
8
  import { PlanCard } from "@/components/landing/PlanCard";
9
+ import brand from "@/brand.config";
10
+ import { catalogCanSearchCountries, countryCatalogMode } from "@/lib/catalog-countries";
11
+ import { presentTierCatalog } from "@/vendor/carrier/tiers";
7
12
 
8
13
  export default async function HomePage() {
9
14
  const plans = await fetchTemplates();
10
- const featured = plans.slice(0, 3);
15
+ const untiered = presentTierCatalog(plans, { lightningEnabled: brand.lightningTier }).untiered;
16
+ const showCountries = catalogCanSearchCountries(
17
+ plans,
18
+ countryCatalogMode(process.env.NEXT_PUBLIC_COUNTRY_CATALOG),
19
+ );
11
20
 
12
21
  return (
13
22
  <>
14
23
  <HeroSection />
15
24
 
25
+ {showCountries && (
26
+ <section className="py-16">
27
+ <CountrySearch plans={plans} />
28
+ </section>
29
+ )}
30
+
16
31
  <section className="px-4 py-20">
17
32
  <div className="mx-auto max-w-6xl">
18
- <p className="text-center text-xs font-semibold uppercase tracking-[0.18em] text-sand-500">
33
+ <p className="text-center text-xs font-semibold uppercase tracking-[0.18em] text-sand-700 dark:text-sand-200">
19
34
  Plans
20
35
  </p>
21
36
  <h2 className="mt-2 text-center text-3xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
22
37
  Popular plans
23
38
  </h2>
24
- <p className="mx-auto mt-3 max-w-lg text-center text-sand-600 dark:text-sand-400">
39
+ <p className="mx-auto mt-3 max-w-lg text-center text-sand-700 dark:text-sand-200">
25
40
  Instant activation. No contracts. Price next to the limit.
26
41
  </p>
27
- <div className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-3">
28
- {featured.map((plan, i) => (
29
- <PlanCard key={plan.id} plan={plan} featured={i === 1} href={`/checkout/${plan.id}`} />
30
- ))}
42
+ <div className="mt-12">
43
+ <TierPlanBoard plans={plans} mode="link" lightningEnabled={brand.lightningTier} />
31
44
  </div>
45
+ {untiered.length > 0 ? (
46
+ <div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-3">
47
+ {untiered.map((plan) => (
48
+ <PlanCard key={plan.id} plan={plan} href={`/checkout/${plan.id}`} />
49
+ ))}
50
+ </div>
51
+ ) : null}
32
52
  </div>
33
53
  </section>
34
54