@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.
- package/dist/{chunk-6OAZJBLA.js → chunk-4XUAUQ3U.js} +67 -7
- package/dist/chunk-4XUAUQ3U.js.map +1 -0
- package/dist/cli.js +55 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.js +66 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/CLERK.md +23 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
- package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
- package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
- package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
- package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
- package/templates/storefront/src/app/dashboard/page.tsx +43 -23
- package/templates/storefront/src/app/globals.css +44 -5
- package/templates/storefront/src/app/help/page.tsx +2 -2
- package/templates/storefront/src/app/layout.tsx +5 -3
- package/templates/storefront/src/app/page.tsx +27 -7
- package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
- package/templates/storefront/src/app/shop/page.tsx +7 -2
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
- package/templates/storefront/src/brand.config.ts +16 -0
- package/templates/storefront/src/components/Providers.tsx +20 -1
- package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
- package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
- package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
- package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
- package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
- package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
- package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
- package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
- package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
- package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
- package/templates/storefront/src/components/theme/brand-style.ts +88 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
- package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
- package/templates/storefront/src/lib/buyer-error.ts +32 -0
- package/templates/storefront/src/lib/buyer-session.ts +41 -0
- package/templates/storefront/src/lib/catalog-countries.ts +179 -0
- package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
- package/templates/storefront/src/lib/checkout-copy.ts +89 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
- package/templates/storefront/src/lib/checkout-return.ts +18 -0
- package/templates/storefront/src/lib/clerk-instance.ts +89 -0
- package/templates/storefront/src/lib/order-reference.ts +15 -0
- package/templates/storefront/src/lib/request-origin.ts +28 -0
- package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
- package/templates/storefront/src/lib/tap-target.ts +17 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
- package/templates/storefront/src/middleware.ts +11 -0
- package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
- package/templates/storefront/src/vendor/carrier/client.ts +22 -4
- package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
- package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
- package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
- package/templates/storefront/src/vendor/carrier/types.ts +22 -0
- package/templates/storefront/src/vendor/geo/index.ts +15 -4
- 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
|
-
*
|
|
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.
|
|
12
|
-
* 2.
|
|
13
|
-
* 3.
|
|
14
|
-
* 4.
|
|
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
|
|
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 {
|
|
20
|
-
import
|
|
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
|
|
118
|
+
const verification = await verifyForPage(sessionId, templateId);
|
|
36
119
|
if (!verification.ok) {
|
|
37
|
-
|
|
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-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
72
|
+
: { phase: "error", message: buyerFacingError("plan", payload) },
|
|
70
73
|
);
|
|
71
74
|
} else {
|
|
72
|
-
setUsage({ phase: "error", message:
|
|
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
|
|
83
|
+
: { phase: "error", message: buyerFacingError("plan", payload) },
|
|
81
84
|
);
|
|
82
85
|
} else {
|
|
83
|
-
setTopUps({ phase: "error", message:
|
|
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(
|
|
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(
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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: "
|
|
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-
|
|
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 ??
|
|
68
|
+
{order?.planName ?? "eSIM order"}
|
|
49
69
|
</p>
|
|
50
|
-
<span className="text-xs text-sand-
|
|
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-
|
|
59
|
-
|
|
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-
|
|
76
|
+
<p className="mt-2 text-xs text-sand-700 dark:text-sand-200">
|
|
62
77
|
We couldn'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-
|
|
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
|
|
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
|
|
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't load {unreadable === orders.length ? "your orders" : "some of your orders"}{" "}
|
|
107
|
-
right now. Nothing is lost — refresh in a minute, or
|
|
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:
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
28
|
-
{
|
|
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
|
|