@carrierllc/mcp 0.2.18 → 0.2.20
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/README.md +22 -1
- package/dist/chunk-NHQDO2E7.js +461 -0
- package/dist/chunk-NHQDO2E7.js.map +1 -0
- package/dist/cli.js +877 -148
- package/dist/cli.js.map +1 -1
- package/dist/index.js +148 -297
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/plugin/carrier/commands/storefront.md +24 -12
- package/plugin/carrier/skills/carrier-operations/SKILL.md +4 -1
- package/templates/storefront/{next.config.ts → next.config.mjs} +2 -6
- package/templates/storefront/package-lock.json +12722 -0
- package/templates/storefront/package.json +3 -3
- package/templates/storefront/public/brand/logo.svg +13 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +12 -12
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +5 -5
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +29 -29
- package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
- package/templates/storefront/src/app/contact/page.tsx +33 -25
- package/templates/storefront/src/app/dashboard/page.tsx +7 -4
- package/templates/storefront/src/app/globals.css +72 -82
- package/templates/storefront/src/app/help/page.tsx +22 -17
- package/templates/storefront/src/app/layout.tsx +15 -4
- package/templates/storefront/src/app/page.tsx +16 -7
- package/templates/storefront/src/app/shop/ShopClient.tsx +7 -3
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +1 -1
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +16 -16
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
- package/templates/storefront/src/brand.config.ts +3 -3
- package/templates/storefront/src/components/faq/FaqSection.tsx +23 -26
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +37 -22
- package/templates/storefront/src/components/landing/FinalCTA.tsx +28 -0
- package/templates/storefront/src/components/landing/HeroSection.tsx +57 -24
- package/templates/storefront/src/components/landing/HowItWorks.tsx +44 -0
- package/templates/storefront/src/components/landing/PlanCard.tsx +58 -28
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +59 -24
- package/templates/storefront/src/components/theme/BrandStyles.tsx +8 -0
- package/templates/storefront/src/components/theme/ThemeToggle.tsx +27 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +33 -35
- package/templates/storefront/src/components/theme/theme-provider.tsx +25 -32
- package/templates/storefront/src/components/theme/theme-script.tsx +4 -7
- package/templates/storefront/src/lib/checkout-order-claim.ts +16 -2
- package/templates/storefront/src/lib/complete-email-sign-up.ts +3 -3
- package/templates/storefront/src/lib/verify-checkout-session.ts +19 -2
- package/templates/storefront/src/vendor/ui/brand.ts +6 -6
|
@@ -1,51 +1,84 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
1
|
import Link from "next/link";
|
|
4
|
-
import { motion } from "framer-motion";
|
|
5
2
|
import brand from "@/brand.config";
|
|
6
3
|
|
|
4
|
+
const STATS = [
|
|
5
|
+
{ value: "200+", label: "countries" },
|
|
6
|
+
{ value: "<30s", label: "to activate" },
|
|
7
|
+
{ value: "24/7", label: "human support" },
|
|
8
|
+
];
|
|
9
|
+
|
|
7
10
|
export function HeroSection() {
|
|
8
11
|
return (
|
|
9
|
-
<section className="relative
|
|
10
|
-
{/* Radial glow */}
|
|
12
|
+
<section className="relative overflow-hidden">
|
|
11
13
|
<div
|
|
14
|
+
aria-hidden
|
|
12
15
|
className="pointer-events-none absolute inset-0"
|
|
13
16
|
style={{
|
|
14
|
-
background: `radial-gradient(ellipse
|
|
17
|
+
background: `radial-gradient(ellipse 70% 50% at 80% 10%, color-mix(in srgb, var(--brand-accent) 22%, transparent), transparent 70%)`,
|
|
15
18
|
}}
|
|
16
19
|
/>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
initial={{ opacity: 0, y: 24 }}
|
|
21
|
-
animate={{ opacity: 1, y: 0 }}
|
|
22
|
-
transition={{ duration: 0.6, ease: "easeOut" }}
|
|
23
|
-
>
|
|
24
|
-
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-[var(--brand-accent)]/30 bg-[var(--brand-accent)]/10 px-3 py-1 text-xs font-medium text-[var(--brand-accent)]">
|
|
20
|
+
<div className="relative mx-auto grid max-w-6xl items-center gap-12 px-4 py-20 md:grid-cols-2 md:py-28">
|
|
21
|
+
<div>
|
|
22
|
+
<p className="mb-5 inline-flex rounded-full border border-sand-200 bg-sand-100/80 px-3 py-1 text-xs font-medium text-sand-700 dark:border-sand-700 dark:bg-sand-900 dark:text-sand-200">
|
|
25
23
|
Global eSIM connectivity
|
|
26
24
|
</p>
|
|
27
|
-
<h1 className="
|
|
28
|
-
Stay connected,
|
|
29
|
-
<span style={{ color: brand
|
|
25
|
+
<h1 className="font-display text-4xl font-semibold tracking-tight text-sand-900 sm:text-5xl md:text-6xl dark:text-sand-50">
|
|
26
|
+
Stay connected,
|
|
27
|
+
<span className="block" style={{ color: "var(--brand-accent)" }}>
|
|
28
|
+
everywhere.
|
|
29
|
+
</span>
|
|
30
30
|
</h1>
|
|
31
|
-
<p className="
|
|
32
|
-
{brand.tagline} Instant
|
|
31
|
+
<p className="mt-5 max-w-md text-base leading-relaxed text-sand-600 dark:text-sand-400">
|
|
32
|
+
{brand.tagline} Instant QR activation. No physical cards. No roaming surprises.
|
|
33
33
|
</p>
|
|
34
|
-
<div className="flex flex-wrap
|
|
34
|
+
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
35
35
|
<Link
|
|
36
36
|
href="/shop"
|
|
37
|
-
className="rounded-full
|
|
37
|
+
className="rounded-full px-7 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
38
|
+
style={{ background: "var(--brand-accent)" }}
|
|
38
39
|
>
|
|
39
40
|
Browse plans
|
|
40
41
|
</Link>
|
|
41
42
|
<Link
|
|
42
|
-
href="
|
|
43
|
-
className="rounded-full border border-white
|
|
43
|
+
href="/#how-it-works"
|
|
44
|
+
className="rounded-full border border-sand-300 bg-white px-7 py-3 text-sm font-semibold text-sand-800 transition-colors hover:border-sand-400 dark:border-sand-700 dark:bg-sand-900 dark:text-sand-100"
|
|
44
45
|
>
|
|
45
46
|
How it works
|
|
46
47
|
</Link>
|
|
47
48
|
</div>
|
|
48
|
-
|
|
49
|
+
<dl className="mt-12 grid max-w-md grid-cols-3 gap-4">
|
|
50
|
+
{STATS.map((stat) => (
|
|
51
|
+
<div key={stat.label}>
|
|
52
|
+
<dt className="text-2xl font-semibold text-sand-900 dark:text-sand-50">{stat.value}</dt>
|
|
53
|
+
<dd className="text-xs text-sand-500">{stat.label}</dd>
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</dl>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="relative hidden md:block">
|
|
59
|
+
<div className="grid grid-cols-2 gap-3">
|
|
60
|
+
{[
|
|
61
|
+
{ src: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=640&q=70&auto=format&fit=crop", label: "Paris" },
|
|
62
|
+
{ src: "https://images.unsplash.com/photo-1492571350019-22de08371fd3?w=640&q=70&auto=format&fit=crop", label: "Tokyo" },
|
|
63
|
+
{ src: "https://images.unsplash.com/photo-1512453979798-5ea266f8880c?w=640&q=70&auto=format&fit=crop", label: "Dubai" },
|
|
64
|
+
{ src: "https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?w=640&q=70&auto=format&fit=crop", label: "London" },
|
|
65
|
+
].map((item) => (
|
|
66
|
+
<figure
|
|
67
|
+
key={item.label}
|
|
68
|
+
className="relative overflow-hidden rounded-2xl border border-sand-200 bg-sand-100 shadow-sm dark:border-sand-800"
|
|
69
|
+
>
|
|
70
|
+
<img
|
|
71
|
+
src={item.src}
|
|
72
|
+
alt={item.label}
|
|
73
|
+
className="aspect-[4/3] w-full object-cover"
|
|
74
|
+
/>
|
|
75
|
+
<figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-sand-950/70 to-transparent px-3 py-2 text-xs font-medium text-white">
|
|
76
|
+
{item.label}
|
|
77
|
+
</figcaption>
|
|
78
|
+
</figure>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
49
82
|
</div>
|
|
50
83
|
</section>
|
|
51
84
|
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const STEPS = [
|
|
2
|
+
{
|
|
3
|
+
n: "01",
|
|
4
|
+
title: "Pick a plan",
|
|
5
|
+
body: "Choose coverage for one country, a region, or the whole map. Price is on the card.",
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
n: "02",
|
|
9
|
+
title: "Pay once",
|
|
10
|
+
body: "Checkout takes a minute. You get a QR code by email as soon as payment clears.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
n: "03",
|
|
14
|
+
title: "Scan and land connected",
|
|
15
|
+
body: "Open Settings, add the eSIM, done. Most people are online in under 30 seconds.",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function HowItWorks() {
|
|
20
|
+
return (
|
|
21
|
+
<section id="how-it-works" className="border-y border-sand-200 bg-sand-100/60 py-20 dark:border-sand-800 dark:bg-sand-900/40">
|
|
22
|
+
<div className="mx-auto max-w-6xl px-4">
|
|
23
|
+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sand-500">How it works</p>
|
|
24
|
+
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
|
|
25
|
+
Connected before you reach the gate.
|
|
26
|
+
</h2>
|
|
27
|
+
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
|
28
|
+
{STEPS.map((step) => (
|
|
29
|
+
<div
|
|
30
|
+
key={step.n}
|
|
31
|
+
className="rounded-2xl border border-sand-200 bg-white p-6 dark:border-sand-800 dark:bg-sand-950"
|
|
32
|
+
>
|
|
33
|
+
<p className="text-sm font-semibold" style={{ color: "var(--brand-accent)" }}>
|
|
34
|
+
{step.n}
|
|
35
|
+
</p>
|
|
36
|
+
<h3 className="mt-3 text-lg font-semibold text-sand-900 dark:text-sand-50">{step.title}</h3>
|
|
37
|
+
<p className="mt-2 text-sm leading-relaxed text-sand-600 dark:text-sand-400">{step.body}</p>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,61 +1,91 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import Link from "next/link";
|
|
3
4
|
import { motion } from "framer-motion";
|
|
4
5
|
import { cn } from "@/vendor/ui/cn";
|
|
5
6
|
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
6
7
|
|
|
7
8
|
interface PlanCardProps {
|
|
8
9
|
plan: SkuTemplate;
|
|
10
|
+
href?: string;
|
|
9
11
|
onSelect?: (plan: SkuTemplate) => void;
|
|
10
12
|
featured?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function PlanCard({ plan, onSelect, featured }: PlanCardProps) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)}
|
|
23
|
-
onClick={() => onSelect?.(plan)}
|
|
24
|
-
>
|
|
15
|
+
export function PlanCard({ plan, href, onSelect, featured }: PlanCardProps) {
|
|
16
|
+
const destination = href ?? (onSelect ? undefined : `/checkout/${plan.id}`);
|
|
17
|
+
|
|
18
|
+
function handleSelect() {
|
|
19
|
+
onSelect?.(plan);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const inner = (
|
|
23
|
+
<>
|
|
25
24
|
{featured && (
|
|
26
|
-
<span
|
|
25
|
+
<span
|
|
26
|
+
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 text-white"
|
|
27
|
+
style={{ background: "var(--brand-accent)" }}
|
|
28
|
+
>
|
|
27
29
|
Most popular
|
|
28
30
|
</span>
|
|
29
31
|
)}
|
|
30
32
|
<div>
|
|
31
|
-
<p className="font-semibold text-
|
|
32
|
-
<p className="text-sm text-
|
|
33
|
+
<p className="font-semibold text-sand-900 dark:text-sand-50">{plan.name}</p>
|
|
34
|
+
<p className="mt-0.5 text-sm text-sand-500">{plan.description}</p>
|
|
33
35
|
</div>
|
|
34
36
|
<div className="flex items-baseline gap-1">
|
|
35
|
-
<span className="text-3xl font-bold text-
|
|
37
|
+
<span className="text-3xl font-bold text-sand-900 dark:text-sand-50">
|
|
36
38
|
€{(plan.price_cents / 100).toFixed(2)}
|
|
37
39
|
</span>
|
|
38
|
-
<span className="text-sm text-
|
|
40
|
+
<span className="text-sm text-sand-500">one-time</span>
|
|
39
41
|
</div>
|
|
40
|
-
<ul className="space-y-1.5 text-sm text-
|
|
41
|
-
{plan.features.map((f
|
|
42
|
-
<li key={
|
|
43
|
-
<span
|
|
42
|
+
<ul className="space-y-1.5 text-sm text-sand-700 dark:text-sand-300">
|
|
43
|
+
{plan.features.map((f) => (
|
|
44
|
+
<li key={f} className="flex items-center gap-2">
|
|
45
|
+
<span style={{ color: "var(--brand-accent)" }}>✓</span>
|
|
44
46
|
{f}
|
|
45
47
|
</li>
|
|
46
48
|
))}
|
|
47
49
|
</ul>
|
|
48
|
-
<
|
|
50
|
+
<span
|
|
49
51
|
className={cn(
|
|
50
|
-
"mt-auto rounded-full py-2 text-sm font-semibold transition-colors",
|
|
52
|
+
"mt-auto block rounded-full py-2 text-center text-sm font-semibold transition-colors",
|
|
51
53
|
featured
|
|
52
|
-
? "
|
|
53
|
-
: "border border-
|
|
54
|
+
? "text-white"
|
|
55
|
+
: "border border-sand-300 text-sand-900 hover:bg-sand-100 dark:border-sand-700 dark:text-sand-50 dark:hover:bg-sand-800",
|
|
54
56
|
)}
|
|
55
|
-
|
|
57
|
+
style={featured ? { background: "var(--brand-accent)" } : undefined}
|
|
56
58
|
>
|
|
57
59
|
Select plan
|
|
58
|
-
</
|
|
59
|
-
|
|
60
|
+
</span>
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const cardClass = cn(
|
|
65
|
+
"relative flex flex-col gap-4 rounded-2xl border p-6 pt-8 transition-colors",
|
|
66
|
+
featured
|
|
67
|
+
? "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)]"
|
|
68
|
+
: "border-sand-200 bg-white hover:border-sand-300 dark:border-sand-800 dark:bg-sand-900 dark:hover:border-sand-700",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (destination && !onSelect) {
|
|
72
|
+
return (
|
|
73
|
+
<motion.div whileHover={{ y: -4 }}>
|
|
74
|
+
<Link href={destination} className={cardClass}>
|
|
75
|
+
{inner}
|
|
76
|
+
</Link>
|
|
77
|
+
</motion.div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<motion.button
|
|
83
|
+
type="button"
|
|
84
|
+
whileHover={{ y: -4 }}
|
|
85
|
+
className={cn(cardClass, "w-full text-left")}
|
|
86
|
+
onClick={handleSelect}
|
|
87
|
+
>
|
|
88
|
+
{inner}
|
|
89
|
+
</motion.button>
|
|
60
90
|
);
|
|
61
91
|
}
|
|
@@ -1,69 +1,104 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useState } from "react";
|
|
3
4
|
import Link from "next/link";
|
|
4
5
|
import { usePathname } from "next/navigation";
|
|
5
6
|
import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
|
|
6
7
|
import { cn } from "@/vendor/ui/cn";
|
|
7
8
|
import brand from "@/brand.config";
|
|
9
|
+
import { ThemeToggle } from "@/components/theme/ThemeToggle";
|
|
8
10
|
|
|
9
11
|
const NAV_LINKS = [
|
|
10
|
-
{ label: "
|
|
12
|
+
{ label: "Plans", href: "/shop" },
|
|
13
|
+
{ label: "How it works", href: "/#how-it-works" },
|
|
11
14
|
{ label: "Help", href: "/help" },
|
|
12
15
|
];
|
|
13
16
|
|
|
14
17
|
export function StorefrontNav() {
|
|
15
18
|
const pathname = usePathname();
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
16
20
|
|
|
17
21
|
return (
|
|
18
|
-
<header className="fixed
|
|
19
|
-
<div className="mx-auto max-w-6xl
|
|
20
|
-
<Link href="/" className="flex items-center gap-2 font-semibold text-
|
|
22
|
+
<header className="fixed inset-x-0 top-0 z-50 border-b border-sand-200/80 bg-sand-50/80 backdrop-blur-md dark:border-sand-800 dark:bg-sand-950/80">
|
|
23
|
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
|
|
24
|
+
<Link href="/" className="flex items-center gap-2 text-base font-semibold tracking-tight text-sand-900 dark:text-sand-50">
|
|
25
|
+
<img src="/brand/logo.svg" alt="" width={32} height={32} className="size-8 rounded-lg" />
|
|
21
26
|
{brand.name}
|
|
22
27
|
</Link>
|
|
23
28
|
|
|
24
|
-
<nav className="hidden
|
|
25
|
-
{NAV_LINKS.map((link) =>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
<nav className="hidden items-center gap-7 md:flex">
|
|
30
|
+
{NAV_LINKS.map((link) => {
|
|
31
|
+
const active = link.href !== "/#how-it-works" && pathname?.startsWith(link.href);
|
|
32
|
+
return (
|
|
33
|
+
<Link
|
|
34
|
+
key={link.href}
|
|
35
|
+
href={link.href}
|
|
36
|
+
className={cn(
|
|
37
|
+
"text-sm transition-colors",
|
|
38
|
+
active
|
|
39
|
+
? "font-medium text-[var(--brand-accent)]"
|
|
40
|
+
: "text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50",
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{link.label}
|
|
44
|
+
</Link>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
39
47
|
</nav>
|
|
40
48
|
|
|
41
|
-
<div className="flex items-center gap-3">
|
|
49
|
+
<div className="flex items-center gap-2 sm:gap-3">
|
|
50
|
+
<ThemeToggle />
|
|
42
51
|
<SignedOut>
|
|
43
52
|
<Link
|
|
44
53
|
href="/sign-in"
|
|
45
|
-
className="text-sm text-
|
|
54
|
+
className="hidden text-sm text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline"
|
|
46
55
|
>
|
|
47
56
|
Sign in
|
|
48
57
|
</Link>
|
|
49
58
|
<Link
|
|
50
59
|
href="/shop"
|
|
51
|
-
className="rounded-full
|
|
60
|
+
className="rounded-full px-4 py-1.5 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
61
|
+
style={{ background: "var(--brand-accent)" }}
|
|
52
62
|
>
|
|
53
|
-
Get
|
|
63
|
+
Get connected
|
|
54
64
|
</Link>
|
|
55
65
|
</SignedOut>
|
|
56
66
|
<SignedIn>
|
|
57
67
|
<Link
|
|
58
68
|
href="/dashboard"
|
|
59
|
-
className="text-sm text-
|
|
69
|
+
className="hidden text-sm text-sand-600 hover:text-sand-900 dark:text-sand-400 dark:hover:text-sand-50 sm:inline"
|
|
60
70
|
>
|
|
61
71
|
Dashboard
|
|
62
72
|
</Link>
|
|
63
73
|
<UserButton afterSignOutUrl="/" />
|
|
64
74
|
</SignedIn>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
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"
|
|
78
|
+
aria-label={open ? "Close menu" : "Open menu"}
|
|
79
|
+
onClick={() => setOpen((v) => !v)}
|
|
80
|
+
>
|
|
81
|
+
<span className="text-lg leading-none">{open ? "×" : "☰"}</span>
|
|
82
|
+
</button>
|
|
65
83
|
</div>
|
|
66
84
|
</div>
|
|
85
|
+
|
|
86
|
+
{open && (
|
|
87
|
+
<div className="border-t border-sand-200 bg-sand-50 px-4 py-4 md:hidden dark:border-sand-800 dark:bg-sand-950">
|
|
88
|
+
<nav className="flex flex-col gap-3">
|
|
89
|
+
{NAV_LINKS.map((link) => (
|
|
90
|
+
<Link
|
|
91
|
+
key={link.href}
|
|
92
|
+
href={link.href}
|
|
93
|
+
onClick={() => setOpen(false)}
|
|
94
|
+
className="text-sm font-medium text-sand-800 dark:text-sand-100"
|
|
95
|
+
>
|
|
96
|
+
{link.label}
|
|
97
|
+
</Link>
|
|
98
|
+
))}
|
|
99
|
+
</nav>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
67
102
|
</header>
|
|
68
103
|
);
|
|
69
104
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import brand from "@/brand.config";
|
|
2
|
+
|
|
3
|
+
/** Inject operator accent at runtime so editing brand.config.ts actually re-skins the shop. */
|
|
4
|
+
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};}`;
|
|
7
|
+
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTheme } from "@/components/theme/theme-provider";
|
|
4
|
+
|
|
5
|
+
export function ThemeToggle() {
|
|
6
|
+
const { theme, toggle } = useTheme();
|
|
7
|
+
const dark = theme === "dark";
|
|
8
|
+
return (
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
onClick={toggle}
|
|
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"
|
|
14
|
+
>
|
|
15
|
+
{dark ? (
|
|
16
|
+
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
17
|
+
<circle cx="12" cy="12" r="4" />
|
|
18
|
+
<path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
|
|
19
|
+
</svg>
|
|
20
|
+
) : (
|
|
21
|
+
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
22
|
+
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z" />
|
|
23
|
+
</svg>
|
|
24
|
+
)}
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,51 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
border: "#
|
|
1
|
+
import brand from "@/brand.config";
|
|
2
|
+
|
|
3
|
+
const accent = brand.colors.accent;
|
|
4
|
+
const sand = {
|
|
5
|
+
paper: "#FAFAF7",
|
|
6
|
+
card: "#FFFFFF",
|
|
7
|
+
ink: "#1A1812",
|
|
8
|
+
muted: "#5C5644",
|
|
9
|
+
border: "#E8E4D8",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
// Using a plain object; Clerk accepts this shape via the `appearance` prop.
|
|
13
|
-
// Typed as `object` to avoid importing Clerk's internal Appearance type.
|
|
14
12
|
export const clerkAppearance: object = {
|
|
15
13
|
variables: {
|
|
16
|
-
colorPrimary:
|
|
17
|
-
colorBackground:
|
|
18
|
-
colorInputBackground:
|
|
19
|
-
colorText:
|
|
20
|
-
colorTextSecondary:
|
|
21
|
-
colorInputText:
|
|
22
|
-
colorNeutral:
|
|
23
|
-
borderRadius: "0.
|
|
14
|
+
colorPrimary: accent,
|
|
15
|
+
colorBackground: sand.card,
|
|
16
|
+
colorInputBackground: sand.paper,
|
|
17
|
+
colorText: sand.ink,
|
|
18
|
+
colorTextSecondary: sand.muted,
|
|
19
|
+
colorInputText: sand.ink,
|
|
20
|
+
colorNeutral: sand.muted,
|
|
21
|
+
borderRadius: "0.75rem",
|
|
24
22
|
fontFamily: "var(--font-geist-sans, system-ui, sans-serif)",
|
|
25
23
|
},
|
|
26
24
|
elements: {
|
|
27
25
|
card: {
|
|
28
|
-
backgroundColor:
|
|
29
|
-
border: `1px solid ${
|
|
26
|
+
backgroundColor: sand.card,
|
|
27
|
+
border: `1px solid ${sand.border}`,
|
|
30
28
|
boxShadow: "none",
|
|
31
29
|
},
|
|
32
|
-
headerTitle: { color:
|
|
33
|
-
headerSubtitle: { color:
|
|
30
|
+
headerTitle: { color: sand.ink },
|
|
31
|
+
headerSubtitle: { color: sand.muted },
|
|
34
32
|
socialButtonsBlockButton: {
|
|
35
|
-
backgroundColor:
|
|
36
|
-
border: `1px solid ${
|
|
37
|
-
color:
|
|
33
|
+
backgroundColor: sand.paper,
|
|
34
|
+
border: `1px solid ${sand.border}`,
|
|
35
|
+
color: sand.ink,
|
|
38
36
|
},
|
|
39
|
-
socialButtonsBlockButtonText: { color:
|
|
40
|
-
dividerLine: { backgroundColor:
|
|
41
|
-
dividerText: { color:
|
|
42
|
-
formFieldLabel: { color:
|
|
37
|
+
socialButtonsBlockButtonText: { color: sand.ink },
|
|
38
|
+
dividerLine: { backgroundColor: sand.border },
|
|
39
|
+
dividerText: { color: sand.muted },
|
|
40
|
+
formFieldLabel: { color: sand.muted },
|
|
43
41
|
formFieldInput: {
|
|
44
|
-
backgroundColor:
|
|
45
|
-
border: `1px solid ${
|
|
46
|
-
color:
|
|
42
|
+
backgroundColor: sand.paper,
|
|
43
|
+
border: `1px solid ${sand.border}`,
|
|
44
|
+
color: sand.ink,
|
|
47
45
|
},
|
|
48
|
-
footerActionLink: { color:
|
|
49
|
-
identityPreviewEditButton: { color:
|
|
46
|
+
footerActionLink: { color: accent },
|
|
47
|
+
identityPreviewEditButton: { color: accent },
|
|
50
48
|
},
|
|
51
49
|
};
|
|
@@ -16,13 +16,11 @@ type ThemeContextValue = {
|
|
|
16
16
|
|
|
17
17
|
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
|
18
18
|
|
|
19
|
-
function systemTheme(): Theme {
|
|
20
|
-
if (typeof window === "undefined") return "dark";
|
|
21
|
-
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
function resolve(pref: ThemePreference): Theme {
|
|
25
|
-
|
|
20
|
+
if (pref === "system" && typeof window !== "undefined") {
|
|
21
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
22
|
+
}
|
|
23
|
+
return pref === "dark" ? "dark" : "light";
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
function applyToDocument(theme: Theme) {
|
|
@@ -32,46 +30,41 @@ function applyToDocument(theme: Theme) {
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
35
|
-
const [preference, setPreferenceState] = React.useState<ThemePreference>(
|
|
36
|
-
|
|
33
|
+
const [preference, setPreferenceState] = React.useState<ThemePreference>("light");
|
|
34
|
+
const [theme, setTheme] = React.useState<Theme>("light");
|
|
35
|
+
|
|
36
|
+
React.useEffect(() => {
|
|
37
37
|
try {
|
|
38
38
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
39
|
-
if (stored === "light" || stored === "dark" || stored === "system")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
if (stored === "light" || stored === "dark" || stored === "system") {
|
|
40
|
+
setPreferenceState(stored);
|
|
41
|
+
const next = resolve(stored);
|
|
42
|
+
setTheme(next);
|
|
43
|
+
applyToDocument(next);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
/* private mode */
|
|
48
|
+
}
|
|
49
|
+
applyToDocument("light");
|
|
50
|
+
}, []);
|
|
50
51
|
|
|
51
52
|
const setPreference = React.useCallback((pref: ThemePreference) => {
|
|
52
53
|
setPreferenceState(pref);
|
|
53
54
|
const next = resolve(pref);
|
|
54
55
|
setTheme(next);
|
|
55
56
|
applyToDocument(next);
|
|
56
|
-
try {
|
|
57
|
+
try {
|
|
58
|
+
window.localStorage.setItem(STORAGE_KEY, pref);
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
57
62
|
}, []);
|
|
58
63
|
|
|
59
64
|
const toggle = React.useCallback(() => {
|
|
60
65
|
setPreference(theme === "dark" ? "light" : "dark");
|
|
61
66
|
}, [theme, setPreference]);
|
|
62
67
|
|
|
63
|
-
React.useEffect(() => {
|
|
64
|
-
if (preference !== "system") return;
|
|
65
|
-
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
66
|
-
const onChange = () => {
|
|
67
|
-
const next = mql.matches ? "dark" : "light";
|
|
68
|
-
setTheme(next);
|
|
69
|
-
applyToDocument(next);
|
|
70
|
-
};
|
|
71
|
-
mql.addEventListener("change", onChange);
|
|
72
|
-
return () => mql.removeEventListener("change", onChange);
|
|
73
|
-
}, [preference]);
|
|
74
|
-
|
|
75
68
|
const value = React.useMemo<ThemeContextValue>(
|
|
76
69
|
() => ({ theme, preference, setPreference, toggle }),
|
|
77
70
|
[theme, preference, setPreference, toggle],
|
|
@@ -4,17 +4,14 @@ const themeScript = `
|
|
|
4
4
|
(function () {
|
|
5
5
|
try {
|
|
6
6
|
var pref = localStorage.getItem("${STORAGE_KEY}");
|
|
7
|
-
var theme
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} else {
|
|
11
|
-
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
12
|
-
}
|
|
7
|
+
var theme = (pref === "light" || pref === "dark")
|
|
8
|
+
? pref
|
|
9
|
+
: "light";
|
|
13
10
|
var root = document.documentElement;
|
|
14
11
|
root.setAttribute("data-theme", theme);
|
|
15
12
|
root.style.colorScheme = theme;
|
|
16
13
|
} catch (e) {
|
|
17
|
-
document.documentElement.setAttribute("data-theme", "
|
|
14
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
18
15
|
}
|
|
19
16
|
})();
|
|
20
17
|
`;
|