@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.
Files changed (69) hide show
  1. package/dist/{chunk-WVXII7ZR.js → chunk-4XUAUQ3U.js} +67 -7
  2. package/dist/chunk-4XUAUQ3U.js.map +1 -0
  3. package/dist/cli.js +56 -10
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +71 -17
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/plugin/carrier/skills/wallet/SKILL.md +1 -1
  9. package/templates/storefront/CLERK.md +23 -0
  10. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
  11. package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
  12. package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
  13. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
  14. package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
  15. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
  16. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
  17. package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
  18. package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
  19. package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
  20. package/templates/storefront/src/app/dashboard/page.tsx +43 -23
  21. package/templates/storefront/src/app/globals.css +44 -5
  22. package/templates/storefront/src/app/help/page.tsx +2 -2
  23. package/templates/storefront/src/app/layout.tsx +5 -3
  24. package/templates/storefront/src/app/page.tsx +27 -7
  25. package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
  26. package/templates/storefront/src/app/shop/page.tsx +7 -2
  27. package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
  28. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
  29. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
  30. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
  31. package/templates/storefront/src/brand.config.ts +16 -0
  32. package/templates/storefront/src/components/Providers.tsx +20 -1
  33. package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
  34. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
  35. package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
  36. package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
  37. package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
  38. package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
  39. package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
  40. package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
  41. package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
  42. package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
  43. package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
  44. package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
  45. package/templates/storefront/src/components/theme/brand-style.ts +88 -0
  46. package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
  47. package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
  48. package/templates/storefront/src/lib/buyer-error.ts +32 -0
  49. package/templates/storefront/src/lib/buyer-session.ts +41 -0
  50. package/templates/storefront/src/lib/catalog-countries.ts +179 -0
  51. package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
  52. package/templates/storefront/src/lib/checkout-copy.ts +89 -0
  53. package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
  54. package/templates/storefront/src/lib/checkout-return.ts +18 -0
  55. package/templates/storefront/src/lib/clerk-instance.ts +89 -0
  56. package/templates/storefront/src/lib/order-reference.ts +15 -0
  57. package/templates/storefront/src/lib/request-origin.ts +28 -0
  58. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
  59. package/templates/storefront/src/lib/tap-target.ts +17 -0
  60. package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
  61. package/templates/storefront/src/middleware.ts +11 -0
  62. package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
  63. package/templates/storefront/src/vendor/carrier/client.ts +22 -4
  64. package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
  65. package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
  66. package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
  67. package/templates/storefront/src/vendor/carrier/types.ts +22 -0
  68. package/templates/storefront/src/vendor/geo/index.ts +15 -4
  69. 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-sm transition-colors",
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-sm text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline"
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-sm text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline"
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-9 items-center justify-center rounded-full border border-sand-200 text-sand-800 md:hidden dark:border-sand-700 dark:text-sand-100"
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-sm font-medium text-sand-800 dark:text-sand-100"
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 { accent, accentDark, accentLight } = brand.colors;
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-9 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"
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
- footerActionLink: { color: accent },
47
- identityPreviewEditButton: { color: accent },
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
+ }