@carrierllc/mcp 0.13.40 → 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-WVXII7ZR.js → chunk-4XUAUQ3U.js} +67 -7
- package/dist/chunk-4XUAUQ3U.js.map +1 -0
- package/dist/cli.js +56 -10
- package/dist/cli.js.map +1 -1
- package/dist/index.js +71 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugin/carrier/skills/wallet/SKILL.md +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-WVXII7ZR.js.map +0 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { formatDataAllowance, formatPrice, formatValidity } from "@/vendor/geo";
|
|
6
|
+
import {
|
|
7
|
+
TIER_HELP_COPY,
|
|
8
|
+
combinations,
|
|
9
|
+
dataChoices,
|
|
10
|
+
presentTierCatalog,
|
|
11
|
+
skuFor,
|
|
12
|
+
tierChoiceSku,
|
|
13
|
+
tierEmptyCopy,
|
|
14
|
+
validityChoices,
|
|
15
|
+
type SpeedTier,
|
|
16
|
+
} from "@/vendor/carrier/tiers";
|
|
17
|
+
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
18
|
+
import { cn } from "@/vendor/ui/cn";
|
|
19
|
+
|
|
20
|
+
interface TierPlanBoardProps {
|
|
21
|
+
plans: SkuTemplate[];
|
|
22
|
+
mode: "select" | "link";
|
|
23
|
+
/** Per-tenant Lightning switch. Off unless the tenant configured it. */
|
|
24
|
+
lightningEnabled?: boolean;
|
|
25
|
+
selectedId?: string | null;
|
|
26
|
+
/** Tier radio. Selects the current data and validity, including a single plan. */
|
|
27
|
+
onSelect?: (plan: SkuTemplate) => void;
|
|
28
|
+
/** Data or validity radio. Does not select a tier and does not scroll. */
|
|
29
|
+
onAdjust?: (plan: SkuTemplate) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const secondary = "text-sand-700 dark:text-sand-200";
|
|
33
|
+
|
|
34
|
+
export function TierPlanBoard({
|
|
35
|
+
plans,
|
|
36
|
+
mode,
|
|
37
|
+
lightningEnabled = false,
|
|
38
|
+
selectedId,
|
|
39
|
+
onSelect,
|
|
40
|
+
onAdjust,
|
|
41
|
+
}: TierPlanBoardProps) {
|
|
42
|
+
const view = presentTierCatalog(plans, { lightningEnabled });
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div role="radiogroup" aria-label="Speed tier" className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
46
|
+
{view.tiers.map((group) => (
|
|
47
|
+
<TierCard
|
|
48
|
+
key={group.tier}
|
|
49
|
+
tier={group.tier}
|
|
50
|
+
plans={group.plans}
|
|
51
|
+
featured={group.tier === "Cruise" && group.plans.length > 0}
|
|
52
|
+
mode={mode}
|
|
53
|
+
selectedId={selectedId}
|
|
54
|
+
onSelect={onSelect}
|
|
55
|
+
onAdjust={onAdjust}
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function TierCard({
|
|
63
|
+
tier,
|
|
64
|
+
plans,
|
|
65
|
+
featured,
|
|
66
|
+
mode,
|
|
67
|
+
selectedId,
|
|
68
|
+
onSelect,
|
|
69
|
+
onAdjust,
|
|
70
|
+
}: {
|
|
71
|
+
tier: SpeedTier;
|
|
72
|
+
plans: SkuTemplate[];
|
|
73
|
+
featured: boolean;
|
|
74
|
+
mode: "select" | "link";
|
|
75
|
+
selectedId?: string | null;
|
|
76
|
+
onSelect?: (plan: SkuTemplate) => void;
|
|
77
|
+
onAdjust?: (plan: SkuTemplate) => void;
|
|
78
|
+
}) {
|
|
79
|
+
const first = combinations(plans)[0];
|
|
80
|
+
const [dataGb, setDataGb] = useState(first?.dataGb ?? 0);
|
|
81
|
+
const [validityDays, setValidityDays] = useState(first?.validityDays ?? 0);
|
|
82
|
+
const sku = skuFor(plans, dataGb, validityDays);
|
|
83
|
+
const selected = sku !== null && sku.id === selectedId;
|
|
84
|
+
|
|
85
|
+
function chooseData(next: number) {
|
|
86
|
+
const days = validityChoices(plans, next);
|
|
87
|
+
const validity = days.includes(validityDays) ? validityDays : days[0];
|
|
88
|
+
if (validity === undefined) return;
|
|
89
|
+
setDataGb(next);
|
|
90
|
+
setValidityDays(validity);
|
|
91
|
+
const nextSku = skuFor(plans, next, validity);
|
|
92
|
+
if (nextSku) onAdjust?.(nextSku);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function chooseValidity(next: number) {
|
|
96
|
+
setValidityDays(next);
|
|
97
|
+
const nextSku = skuFor(plans, dataGb, next);
|
|
98
|
+
if (nextSku) onAdjust?.(nextSku);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function selectTier() {
|
|
102
|
+
const choice = sku ?? tierChoiceSku(plans);
|
|
103
|
+
if (choice) onSelect?.(choice);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<article
|
|
108
|
+
data-tier={tier}
|
|
109
|
+
className={cn(
|
|
110
|
+
"relative flex flex-col gap-4 rounded-2xl border p-6",
|
|
111
|
+
selected && "border-[var(--brand-accent)]",
|
|
112
|
+
featured
|
|
113
|
+
? "border-[var(--brand-accent)] bg-[color-mix(in_srgb,var(--brand-accent)_6%,white)] dark:bg-[color-mix(in_srgb,var(--brand-accent)_12%,#0F0E0A)]"
|
|
114
|
+
: "border-sand-200 bg-white dark:border-sand-700 dark:bg-sand-900",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{featured ? (
|
|
118
|
+
<span
|
|
119
|
+
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
|
120
|
+
style={{ background: "var(--brand-accent)", color: "var(--brand-on-accent)" }}
|
|
121
|
+
>
|
|
122
|
+
Most popular
|
|
123
|
+
</span>
|
|
124
|
+
) : null}
|
|
125
|
+
<div>
|
|
126
|
+
<h3 className="font-semibold text-sand-900 dark:text-sand-50">{tier}</h3>
|
|
127
|
+
<p className={cn("mt-0.5 text-sm", secondary)}>{plans.length === 0 ? tierEmptyCopy(tier) : TIER_HELP_COPY}</p>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{plans.length === 0 ? null : (
|
|
131
|
+
<>
|
|
132
|
+
{mode === "select" ? (
|
|
133
|
+
<label className={cn("inline-flex min-h-11 cursor-pointer items-center gap-2 text-base font-semibold text-sand-900 dark:text-sand-50")}>
|
|
134
|
+
<input
|
|
135
|
+
type="radio"
|
|
136
|
+
name="speed-tier"
|
|
137
|
+
value={tier}
|
|
138
|
+
checked={selected}
|
|
139
|
+
aria-checked={selected}
|
|
140
|
+
onChange={selectTier}
|
|
141
|
+
/>
|
|
142
|
+
<span>{selected ? "Selected" : `Select ${tier}`}</span>
|
|
143
|
+
{selected ? (
|
|
144
|
+
<span aria-hidden="true" className="text-[var(--brand-accent-text)]">
|
|
145
|
+
✓
|
|
146
|
+
</span>
|
|
147
|
+
) : null}
|
|
148
|
+
</label>
|
|
149
|
+
) : null}
|
|
150
|
+
<ChoiceGroup
|
|
151
|
+
legend="Data"
|
|
152
|
+
name={`${tier}-data`}
|
|
153
|
+
value={dataGb}
|
|
154
|
+
options={dataChoices(plans).map((amount) => ({
|
|
155
|
+
value: amount,
|
|
156
|
+
label: formatDataAllowance(amount, plans.find((plan) => plan.dataGb === amount)?.unlimited),
|
|
157
|
+
}))}
|
|
158
|
+
onChange={chooseData}
|
|
159
|
+
/>
|
|
160
|
+
<ChoiceGroup
|
|
161
|
+
legend="Validity"
|
|
162
|
+
name={`${tier}-validity`}
|
|
163
|
+
value={validityDays}
|
|
164
|
+
options={validityChoices(plans, dataGb).map((days) => ({
|
|
165
|
+
value: days,
|
|
166
|
+
label: formatValidity(days),
|
|
167
|
+
}))}
|
|
168
|
+
onChange={chooseValidity}
|
|
169
|
+
/>
|
|
170
|
+
{sku ? (
|
|
171
|
+
sku.price_unavailable ? (
|
|
172
|
+
<p className="text-3xl font-bold text-sand-900 dark:text-sand-50" data-sku={sku.id} aria-live="polite">
|
|
173
|
+
Unavailable
|
|
174
|
+
</p>
|
|
175
|
+
) : (
|
|
176
|
+
<p
|
|
177
|
+
className="text-3xl font-bold text-sand-900 dark:text-sand-50"
|
|
178
|
+
data-unit-amount={sku.price_cents}
|
|
179
|
+
data-currency={sku.currency}
|
|
180
|
+
data-sku={sku.id}
|
|
181
|
+
aria-live="polite"
|
|
182
|
+
>
|
|
183
|
+
{formatPrice(sku.price_cents, sku.currency)}
|
|
184
|
+
</p>
|
|
185
|
+
)
|
|
186
|
+
) : null}
|
|
187
|
+
{mode === "link" && sku && sku.price_unavailable !== true ? (
|
|
188
|
+
<Link
|
|
189
|
+
href={`/checkout/${sku.id}`}
|
|
190
|
+
className="mt-auto inline-flex min-h-11 items-center justify-center rounded-full px-6 text-center text-base font-semibold"
|
|
191
|
+
style={{ background: "var(--brand-accent)", color: "var(--brand-on-accent)" }}
|
|
192
|
+
>
|
|
193
|
+
Select plan
|
|
194
|
+
</Link>
|
|
195
|
+
) : null}
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
</article>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ChoiceGroup({
|
|
203
|
+
legend,
|
|
204
|
+
name,
|
|
205
|
+
value,
|
|
206
|
+
options,
|
|
207
|
+
onChange,
|
|
208
|
+
}: {
|
|
209
|
+
legend: string;
|
|
210
|
+
name: string;
|
|
211
|
+
value: number;
|
|
212
|
+
options: Array<{ value: number; label: string }>;
|
|
213
|
+
onChange: (value: number) => void;
|
|
214
|
+
}) {
|
|
215
|
+
return (
|
|
216
|
+
<fieldset className="space-y-2">
|
|
217
|
+
<legend className={cn("text-xs font-semibold uppercase tracking-widest", secondary)}>{legend}</legend>
|
|
218
|
+
<div className="flex flex-wrap gap-2">
|
|
219
|
+
{options.map((option) => (
|
|
220
|
+
<label
|
|
221
|
+
key={option.value}
|
|
222
|
+
className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border border-sand-300 px-3 text-base text-sand-900 dark:border-sand-600 dark:text-sand-50"
|
|
223
|
+
>
|
|
224
|
+
<input
|
|
225
|
+
type="radio"
|
|
226
|
+
name={name}
|
|
227
|
+
value={option.value}
|
|
228
|
+
checked={option.value === value}
|
|
229
|
+
onChange={() => onChange(option.value)}
|
|
230
|
+
/>
|
|
231
|
+
{option.label}
|
|
232
|
+
</label>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
</fieldset>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -8,6 +8,7 @@ import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
|
|
|
8
8
|
import { cn } from "@/vendor/ui/cn";
|
|
9
9
|
import brand from "@/brand.config";
|
|
10
10
|
import { ThemeToggle } from "@/components/theme/ThemeToggle";
|
|
11
|
+
import { filledAccentClass } from "@/lib/tap-target";
|
|
11
12
|
|
|
12
13
|
const NAV_LINKS = [
|
|
13
14
|
{ label: "Plans", href: "/shop" },
|
|
@@ -35,9 +36,9 @@ export function StorefrontNav() {
|
|
|
35
36
|
key={link.href}
|
|
36
37
|
href={link.href}
|
|
37
38
|
className={cn(
|
|
38
|
-
"text-
|
|
39
|
+
"inline-flex min-h-11 items-center text-base transition-colors",
|
|
39
40
|
active
|
|
40
|
-
? "font-medium text-[var(--brand-accent)]"
|
|
41
|
+
? "font-medium text-[var(--brand-accent-text)]"
|
|
41
42
|
: "text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50",
|
|
42
43
|
)}
|
|
43
44
|
>
|
|
@@ -52,22 +53,18 @@ export function StorefrontNav() {
|
|
|
52
53
|
<SignedOut>
|
|
53
54
|
<Link
|
|
54
55
|
href="/sign-in"
|
|
55
|
-
className="hidden text-
|
|
56
|
+
className="hidden min-h-11 items-center text-base text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline-flex"
|
|
56
57
|
>
|
|
57
58
|
Sign in
|
|
58
59
|
</Link>
|
|
59
|
-
<Link
|
|
60
|
-
href="/shop"
|
|
61
|
-
className="rounded-full px-4 py-1.5 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
62
|
-
style={{ background: "var(--brand-accent)" }}
|
|
63
|
-
>
|
|
60
|
+
<Link href="/shop" className={filledAccentClass}>
|
|
64
61
|
Get connected
|
|
65
62
|
</Link>
|
|
66
63
|
</SignedOut>
|
|
67
64
|
<SignedIn>
|
|
68
65
|
<Link
|
|
69
66
|
href="/dashboard"
|
|
70
|
-
className="hidden text-
|
|
67
|
+
className="hidden min-h-11 items-center text-base text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline-flex"
|
|
71
68
|
>
|
|
72
69
|
Dashboard
|
|
73
70
|
</Link>
|
|
@@ -75,7 +72,7 @@ export function StorefrontNav() {
|
|
|
75
72
|
</SignedIn>
|
|
76
73
|
<button
|
|
77
74
|
type="button"
|
|
78
|
-
className="inline-flex size-
|
|
75
|
+
className="inline-flex size-11 min-h-11 min-w-11 items-center justify-center rounded-full border border-sand-200 text-sand-800 md:hidden dark:border-sand-700 dark:text-sand-100"
|
|
79
76
|
aria-label={open ? "Close menu" : "Open menu"}
|
|
80
77
|
onClick={() => setOpen((v) => !v)}
|
|
81
78
|
>
|
|
@@ -92,7 +89,7 @@ export function StorefrontNav() {
|
|
|
92
89
|
key={link.href}
|
|
93
90
|
href={link.href}
|
|
94
91
|
onClick={() => setOpen(false)}
|
|
95
|
-
className="text-
|
|
92
|
+
className="inline-flex min-h-11 items-center text-base font-medium text-sand-800 dark:text-sand-100"
|
|
96
93
|
>
|
|
97
94
|
{link.label}
|
|
98
95
|
</Link>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { countryOffers, visibleCountryOffers } from "@/vendor/carrier/country-offers";
|
|
6
|
+
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
7
|
+
import { formatPrice } from "@/vendor/geo";
|
|
8
|
+
import { fieldClass, textLinkClass } from "@/lib/tap-target";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
plans: SkuTemplate[];
|
|
12
|
+
/** Pre-fills the box. A two-letter code matches that country exactly. */
|
|
13
|
+
initialQuery?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CountrySearch({ plans, initialQuery = "" }: Props) {
|
|
17
|
+
const [query, setQuery] = useState(initialQuery);
|
|
18
|
+
const offers = useMemo(
|
|
19
|
+
() =>
|
|
20
|
+
countryOffers(
|
|
21
|
+
plans.map((plan) => ({
|
|
22
|
+
id: plan.id,
|
|
23
|
+
priceCents: plan.price_cents,
|
|
24
|
+
currency: plan.currency,
|
|
25
|
+
regions: plan.regions,
|
|
26
|
+
})),
|
|
27
|
+
),
|
|
28
|
+
[plans],
|
|
29
|
+
);
|
|
30
|
+
const rows = useMemo(() => visibleCountryOffers(offers, query), [offers, query]);
|
|
31
|
+
const searching = query.trim() !== "";
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<section aria-label="Countries" className="mx-auto w-full max-w-6xl min-w-0 px-4">
|
|
35
|
+
<label htmlFor="country-search" className="block text-xs font-semibold uppercase tracking-[0.18em] text-sand-500">
|
|
36
|
+
Countries
|
|
37
|
+
</label>
|
|
38
|
+
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
|
|
39
|
+
Where are you going?
|
|
40
|
+
</h2>
|
|
41
|
+
<p className="mt-2 max-w-lg text-sand-600 dark:text-sand-400">
|
|
42
|
+
Search the countries this shop sells. The list updates as you type.
|
|
43
|
+
</p>
|
|
44
|
+
<input
|
|
45
|
+
id="country-search"
|
|
46
|
+
type="search"
|
|
47
|
+
enterKeyHint="search"
|
|
48
|
+
autoComplete="off"
|
|
49
|
+
placeholder="Try Germany or Japan"
|
|
50
|
+
value={query}
|
|
51
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
52
|
+
aria-controls="country-list"
|
|
53
|
+
className={`${fieldClass} mt-6`}
|
|
54
|
+
/>
|
|
55
|
+
<p className="mt-3 text-sm text-sand-600 dark:text-sand-400" aria-live="polite">
|
|
56
|
+
{searching
|
|
57
|
+
? rows.length === 1
|
|
58
|
+
? "1 country"
|
|
59
|
+
: `${rows.length} countries`
|
|
60
|
+
: "Popular countries. Type to search the rest."}
|
|
61
|
+
</p>
|
|
62
|
+
<ul id="country-list" className="mt-2 divide-y divide-sand-200 dark:divide-sand-800">
|
|
63
|
+
{rows.map((offer) => {
|
|
64
|
+
const price =
|
|
65
|
+
offer.fromCents !== null && offer.currency
|
|
66
|
+
? formatPrice(offer.fromCents, offer.currency)
|
|
67
|
+
: null;
|
|
68
|
+
return (
|
|
69
|
+
<li key={offer.code}>
|
|
70
|
+
<Link
|
|
71
|
+
href={`/shop?country=${offer.code}`}
|
|
72
|
+
data-country={offer.code}
|
|
73
|
+
className="flex min-h-11 min-w-0 items-center justify-between gap-3 py-2 text-base text-sand-900 dark:text-sand-50"
|
|
74
|
+
>
|
|
75
|
+
<span className="min-w-0 break-words">{offer.name}</span>
|
|
76
|
+
<span className="shrink-0 font-mono text-sm text-sand-500">
|
|
77
|
+
{price ? `From ${price}` : offer.code}
|
|
78
|
+
</span>
|
|
79
|
+
</Link>
|
|
80
|
+
</li>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</ul>
|
|
84
|
+
{searching && rows.length === 0 && (
|
|
85
|
+
<p className="mt-4 text-base text-sand-700 dark:text-sand-200">
|
|
86
|
+
No countries match that search.{" "}
|
|
87
|
+
<Link href="/shop" className={textLinkClass}>
|
|
88
|
+
Browse plans
|
|
89
|
+
</Link>
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import brand from "@/brand.config";
|
|
2
|
+
import { brandStyleCss } from "@/components/theme/brand-style";
|
|
2
3
|
|
|
3
4
|
/** Inject operator accent at runtime so editing brand.config.ts actually re-skins the shop. */
|
|
4
5
|
export function BrandStyles() {
|
|
5
|
-
const
|
|
6
|
-
const css = `:root{--brand-accent:${accent};--brand-accent-dark:${accentDark};--brand-accent-light:${accentLight};}`;
|
|
6
|
+
const css = brandStyleCss(brand.colors);
|
|
7
7
|
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
|
8
8
|
}
|
|
@@ -10,7 +10,7 @@ export function ThemeToggle() {
|
|
|
10
10
|
type="button"
|
|
11
11
|
onClick={toggle}
|
|
12
12
|
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
|
|
13
|
-
className="inline-flex size-
|
|
13
|
+
className="inline-flex size-11 min-h-11 min-w-11 items-center justify-center rounded-full border border-sand-200 text-sand-700 transition-colors hover:border-sand-400 hover:text-sand-900 dark:border-sand-700 dark:text-sand-200 dark:hover:border-sand-500 dark:hover:text-sand-50"
|
|
14
14
|
>
|
|
15
15
|
{dark ? (
|
|
16
16
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime accent sheet.
|
|
3
|
+
*
|
|
4
|
+
* The dark rule is unlayered and more specific than `:root`, so it wins over
|
|
5
|
+
* both the light value and any `@layer base` copy of the light token.
|
|
6
|
+
*/
|
|
7
|
+
export function brandAccentStyleSheet(colors: {
|
|
8
|
+
accent: string;
|
|
9
|
+
accentDark: string;
|
|
10
|
+
accentLight: string;
|
|
11
|
+
onAccent: string;
|
|
12
|
+
accentText: string;
|
|
13
|
+
accentTextDark: string;
|
|
14
|
+
}): string {
|
|
15
|
+
return (
|
|
16
|
+
`:root{--brand-accent:${colors.accent};--brand-accent-dark:${colors.accentDark};` +
|
|
17
|
+
`--brand-accent-light:${colors.accentLight};--brand-on-accent:${colors.onAccent};` +
|
|
18
|
+
`--brand-accent-text:${colors.accentText};}` +
|
|
19
|
+
`html[data-theme="dark"]{--brand-accent-text:${colors.accentTextDark};}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accent tokens for the storefront template.
|
|
3
|
+
* Emitted inside `@layer base`. Dark values apply only under `html[data-theme="dark"]`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FLAME = "#FF6B35";
|
|
7
|
+
export const FLAME_TEXT_LIGHT = "#C2410C";
|
|
8
|
+
export const FLAME_TEXT_DARK = "#FF8A5C";
|
|
9
|
+
export const SAND_50 = "#FAFAF7";
|
|
10
|
+
export const SAND_800 = "#2C291F";
|
|
11
|
+
export const SAND_900 = "#1A1812";
|
|
12
|
+
export const SAND_950 = "#0F0E0A";
|
|
13
|
+
|
|
14
|
+
export interface BrandColors {
|
|
15
|
+
accent: string;
|
|
16
|
+
accentDark: string;
|
|
17
|
+
accentLight: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function contrastRatio(foreground: string, background: string): number {
|
|
21
|
+
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background));
|
|
22
|
+
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background));
|
|
23
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readableOn(background: string): "#111111" | "#FFFFFF" {
|
|
27
|
+
const black = contrastRatio("#111111", background);
|
|
28
|
+
const white = contrastRatio("#FFFFFF", background);
|
|
29
|
+
if (black >= 4.5 && black >= white) return "#111111";
|
|
30
|
+
if (white >= 4.5) return "#FFFFFF";
|
|
31
|
+
return black >= white ? "#111111" : "#FFFFFF";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Accent-coloured text. Flame uses the checked pair. Any other accent is shaded until it clears 4.5:1. */
|
|
35
|
+
export function accentText(accent: string, background: string, mode: "light" | "dark"): string {
|
|
36
|
+
if (accent.trim().toUpperCase() === FLAME) return mode === "light" ? FLAME_TEXT_LIGHT : FLAME_TEXT_DARK;
|
|
37
|
+
const toward = mode === "light" ? "#000000" : "#FFFFFF";
|
|
38
|
+
if (contrastRatio(accent, background) >= 4.5) return accent.trim().toUpperCase();
|
|
39
|
+
for (let step = 1; step <= 20; step += 1) {
|
|
40
|
+
const mixed = mix(accent, toward, step / 20);
|
|
41
|
+
if (contrastRatio(mixed, background) >= 4.5) return mixed;
|
|
42
|
+
}
|
|
43
|
+
return readableOn(background);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Focus ring. At least 3:1 on the background it is drawn on. */
|
|
47
|
+
export function focusColor(accent: string, background: string, mode: "light" | "dark"): string {
|
|
48
|
+
const text = accentText(accent, background, mode);
|
|
49
|
+
if (contrastRatio(text, background) >= 3) return text;
|
|
50
|
+
return readableOn(background);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function brandStyleCss(colors: BrandColors): string {
|
|
54
|
+
const accent = colors.accent.trim();
|
|
55
|
+
const onAccent = readableOn(accent);
|
|
56
|
+
const textLight = accentText(accent, SAND_50, "light");
|
|
57
|
+
const textDark = accentText(accent, SAND_950, "dark");
|
|
58
|
+
const focusLight = focusColor(accent, SAND_50, "light");
|
|
59
|
+
const focusDark = focusColor(accent, SAND_800, "dark");
|
|
60
|
+
const light = `:root{--brand-accent:${accent};--brand-accent-dark:${colors.accentDark};--brand-accent-light:${colors.accentLight};--brand-on-accent:${onAccent};--brand-accent-text:${textLight};--brand-focus:${focusLight};}`;
|
|
61
|
+
const dark = `--brand-accent-text:${textDark};--brand-focus:${focusDark};`;
|
|
62
|
+
return `@layer base{${light}html[data-theme="dark"]{${dark}}}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function relativeLuminance(hex: string): number {
|
|
66
|
+
const value = hex.trim().replace(/^#/, "");
|
|
67
|
+
const channel = (index: number) => {
|
|
68
|
+
const unit = Number.parseInt(value.slice(index, index + 2), 16) / 255;
|
|
69
|
+
return unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
|
|
70
|
+
};
|
|
71
|
+
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mix(from: string, to: string, amount: number): string {
|
|
75
|
+
const left = rgb(from);
|
|
76
|
+
const right = rgb(to);
|
|
77
|
+
const channel = (index: number) => Math.round(left[index] + (right[index] - left[index]) * amount);
|
|
78
|
+
return `#${[0, 1, 2].map((index) => channel(index).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rgb(hex: string): [number, number, number] {
|
|
82
|
+
const value = hex.trim().replace(/^#/, "");
|
|
83
|
+
return [
|
|
84
|
+
Number.parseInt(value.slice(0, 2), 16),
|
|
85
|
+
Number.parseInt(value.slice(2, 4), 16),
|
|
86
|
+
Number.parseInt(value.slice(4, 6), 16),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import brand from "@/brand.config";
|
|
2
2
|
|
|
3
3
|
const accent = brand.colors.accent;
|
|
4
|
+
const onAccent = "#111111";
|
|
5
|
+
const accentText = "#C2410C";
|
|
4
6
|
const sand = {
|
|
5
7
|
paper: "#FAFAF7",
|
|
6
8
|
card: "#FFFFFF",
|
|
@@ -33,6 +35,8 @@ export const clerkAppearance: object = {
|
|
|
33
35
|
backgroundColor: sand.paper,
|
|
34
36
|
border: `1px solid ${sand.border}`,
|
|
35
37
|
color: sand.ink,
|
|
38
|
+
fontSize: "16px",
|
|
39
|
+
minHeight: "44px",
|
|
36
40
|
},
|
|
37
41
|
socialButtonsBlockButtonText: { color: sand.ink },
|
|
38
42
|
dividerLine: { backgroundColor: sand.border },
|
|
@@ -42,8 +46,20 @@ export const clerkAppearance: object = {
|
|
|
42
46
|
backgroundColor: sand.paper,
|
|
43
47
|
border: `1px solid ${sand.border}`,
|
|
44
48
|
color: sand.ink,
|
|
49
|
+
fontSize: "16px",
|
|
50
|
+
minHeight: "44px",
|
|
45
51
|
},
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
formButtonPrimary: {
|
|
53
|
+
backgroundColor: accent,
|
|
54
|
+
color: onAccent,
|
|
55
|
+
fontSize: "16px",
|
|
56
|
+
minHeight: "44px",
|
|
57
|
+
},
|
|
58
|
+
footerActionLink: { color: accentText, fontSize: "16px", minHeight: "44px" },
|
|
59
|
+
formFieldAction: { color: accentText, fontSize: "16px", minHeight: "44px" },
|
|
60
|
+
identityPreviewEditButton: { color: accentText, minHeight: "44px" },
|
|
61
|
+
avatarBox: { width: "44px", height: "44px" },
|
|
62
|
+
organizationSwitcherTrigger: { display: "none" },
|
|
63
|
+
organizationListCreateOrganizationActionButton: { display: "none" },
|
|
48
64
|
},
|
|
49
65
|
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer-facing sign-in copy.
|
|
3
|
+
*
|
|
4
|
+
* The words are the same whether or not an account exists. Clerk's own
|
|
5
|
+
* error text names that difference, so it never reaches the page.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const BUYER_AUTH_COPY = {
|
|
9
|
+
rejected: "Email or password is wrong.",
|
|
10
|
+
sessionExists: "You're already signed in on this browser. Open your orders, or sign out first.",
|
|
11
|
+
resetSent: "If an account exists, we've sent a code.",
|
|
12
|
+
secondFactor:
|
|
13
|
+
"This account needs a second step this page can't finish. Use Forgot password, or contact the shop.",
|
|
14
|
+
resetRejected: "That code didn't work. Request a new one.",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** A finished sign-in continues. Anything else gets a clear next step, not a blank page. */
|
|
18
|
+
export function signInStatusCopy(status: string | null | undefined): "complete" | "second-factor" {
|
|
19
|
+
return status === "complete" ? "complete" : "second-factor";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The reset screen after the email step.
|
|
24
|
+
*
|
|
25
|
+
* Callers use this on success and on failure. The page must not grow a
|
|
26
|
+
* second message for "no such account".
|
|
27
|
+
*/
|
|
28
|
+
export function resetEmailOutcome(): { step: "reset-password"; message: string } {
|
|
29
|
+
return { step: "reset-password", message: BUYER_AUTH_COPY.resetSent };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Buyer sentences for Clerk error codes.
|
|
34
|
+
*
|
|
35
|
+
* The raw `message` and `longMessage` stay off the page. An unknown code
|
|
36
|
+
* uses the caller's fallback, which is the same sentence for "no such
|
|
37
|
+
* account" and "wrong password" on sign-in.
|
|
38
|
+
*/
|
|
39
|
+
export const CLERK_ERROR_COPY: Record<string, string> = {
|
|
40
|
+
form_identifier_not_found: BUYER_AUTH_COPY.rejected,
|
|
41
|
+
form_password_incorrect: BUYER_AUTH_COPY.rejected,
|
|
42
|
+
form_password_pwned: "Choose a password that is harder to guess.",
|
|
43
|
+
form_password_length_too_short: "Use at least 8 characters.",
|
|
44
|
+
form_password_not_strong_enough: "Choose a stronger password.",
|
|
45
|
+
form_password_validation_failed: "That password doesn't meet the rules.",
|
|
46
|
+
form_code_incorrect: BUYER_AUTH_COPY.resetRejected,
|
|
47
|
+
form_param_format_invalid: "Check what you typed and try again.",
|
|
48
|
+
form_param_nil: "Fill in every field and try again.",
|
|
49
|
+
form_identifier_exists: "An account with that email already exists. Sign in instead.",
|
|
50
|
+
too_many_requests: "Too many attempts. Wait a moment and try again.",
|
|
51
|
+
user_locked: "This account is locked. Contact the shop.",
|
|
52
|
+
session_exists: BUYER_AUTH_COPY.sessionExists,
|
|
53
|
+
identifier_already_signed_in: BUYER_AUTH_COPY.sessionExists,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Codes that do not tell a stranger whether the account exists. */
|
|
57
|
+
export const SIGN_IN_CLERK_CODES = [
|
|
58
|
+
"form_identifier_not_found",
|
|
59
|
+
"form_password_incorrect",
|
|
60
|
+
"form_param_format_invalid",
|
|
61
|
+
"too_many_requests",
|
|
62
|
+
"session_exists",
|
|
63
|
+
"identifier_already_signed_in",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
export function buyerCopyForClerkError(
|
|
67
|
+
err: unknown,
|
|
68
|
+
fallback: string,
|
|
69
|
+
allowedCodes?: readonly string[],
|
|
70
|
+
): string {
|
|
71
|
+
const code = clerkErrorCode(err);
|
|
72
|
+
if (!code) return fallback;
|
|
73
|
+
if (allowedCodes && !allowedCodes.includes(code)) return fallback;
|
|
74
|
+
return CLERK_ERROR_COPY[code] ?? fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clerkErrorCode(err: unknown): string | undefined {
|
|
78
|
+
if (!err || typeof err !== "object") return undefined;
|
|
79
|
+
const errors = (err as { errors?: unknown }).errors;
|
|
80
|
+
if (!Array.isArray(errors)) return undefined;
|
|
81
|
+
const code = (errors[0] as { code?: unknown } | undefined)?.code;
|
|
82
|
+
return typeof code === "string" && code.trim() !== "" ? code : undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy a buyer may see when a dashboard or activate action fails.
|
|
3
|
+
* The kind chooses the sentence. A server string or a browser error never does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const PLAN_UNREACHABLE =
|
|
7
|
+
"We couldn't reach your plan just now. Nothing was changed. Try again in a minute.";
|
|
8
|
+
export const PHONE_UNSAVED =
|
|
9
|
+
"We couldn't save that number. Nothing else changed. Try again in a minute.";
|
|
10
|
+
|
|
11
|
+
export type BuyerErrorKind = "plan" | "phone";
|
|
12
|
+
|
|
13
|
+
export function buyerFacingError(kind: BuyerErrorKind, _detail?: unknown): string {
|
|
14
|
+
switch (kind) {
|
|
15
|
+
case "plan":
|
|
16
|
+
return PLAN_UNREACHABLE;
|
|
17
|
+
case "phone":
|
|
18
|
+
return PHONE_UNSAVED;
|
|
19
|
+
default: {
|
|
20
|
+
const unknown: never = kind;
|
|
21
|
+
return unknown;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readIgnoredJson(response: Response): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
await response.json();
|
|
29
|
+
} catch {
|
|
30
|
+
// The status already chose the copy.
|
|
31
|
+
}
|
|
32
|
+
}
|