@carrierllc/mcp 0.2.16 → 0.2.17
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/cli.js +644 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +1263 -233
- package/dist/index.js.map +1 -1
- package/package.json +21 -14
- package/plugin/.claude-plugin/marketplace.json +31 -0
- package/plugin/carrier/.claude-plugin/plugin.json +19 -0
- package/plugin/carrier/.mcp.json +8 -0
- package/plugin/carrier/README.md +48 -0
- package/plugin/carrier/agents/carrier-billing-auditor.md +16 -0
- package/plugin/carrier/agents/carrier-fleet-ops.md +15 -0
- package/plugin/carrier/agents/carrier-storefront-builder.md +17 -0
- package/plugin/carrier/commands/billing.md +16 -0
- package/plugin/carrier/commands/churn.md +15 -0
- package/plugin/carrier/commands/credits.md +15 -0
- package/plugin/carrier/commands/esim-status.md +15 -0
- package/plugin/carrier/commands/fleet.md +17 -0
- package/plugin/carrier/commands/greenzone.md +16 -0
- package/plugin/carrier/commands/onboard.md +17 -0
- package/plugin/carrier/commands/packages.md +18 -0
- package/plugin/carrier/commands/provision.md +20 -0
- package/plugin/carrier/commands/sms.md +18 -0
- package/plugin/carrier/commands/status.md +15 -0
- package/plugin/carrier/commands/storefront.md +21 -0
- package/plugin/carrier/commands/subscribers.md +18 -0
- package/plugin/carrier/commands/usage.md +16 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +36 -0
- package/templates/storefront/eslint.config.mjs +15 -0
- package/templates/storefront/next.config.ts +22 -0
- package/templates/storefront/open-next.config.ts +3 -0
- package/templates/storefront/package.json +38 -0
- package/templates/storefront/pnpm-lock.yaml +4020 -0
- package/templates/storefront/postcss.config.mjs +7 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +28 -0
- package/templates/storefront/src/app/apple-icon.tsx +29 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +52 -0
- package/templates/storefront/src/app/checkout/[templateId]/page.tsx +24 -0
- package/templates/storefront/src/app/contact/page.tsx +35 -0
- package/templates/storefront/src/app/dashboard/page.tsx +26 -0
- package/templates/storefront/src/app/globals.css +99 -0
- package/templates/storefront/src/app/help/page.tsx +25 -0
- package/templates/storefront/src/app/icon.tsx +29 -0
- package/templates/storefront/src/app/layout.tsx +56 -0
- package/templates/storefront/src/app/legal/acceptable-use/page.tsx +21 -0
- package/templates/storefront/src/app/legal/privacy/page.tsx +27 -0
- package/templates/storefront/src/app/legal/terms/page.tsx +25 -0
- package/templates/storefront/src/app/manifest.ts +18 -0
- package/templates/storefront/src/app/page.tsx +31 -0
- package/templates/storefront/src/app/robots.ts +9 -0
- package/templates/storefront/src/app/shop/ShopClient.tsx +27 -0
- package/templates/storefront/src/app/shop/page.tsx +9 -0
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +24 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +13 -0
- package/templates/storefront/src/app/sitemap.ts +14 -0
- package/templates/storefront/src/brand.config.ts +28 -0
- package/templates/storefront/src/components/Providers.tsx +16 -0
- package/templates/storefront/src/components/faq/FaqSection.tsx +80 -0
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +61 -0
- package/templates/storefront/src/components/landing/HeroSection.tsx +52 -0
- package/templates/storefront/src/components/landing/PlanCard.tsx +61 -0
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +69 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +51 -0
- package/templates/storefront/src/components/theme/theme-provider.tsx +87 -0
- package/templates/storefront/src/components/theme/theme-script.tsx +24 -0
- package/templates/storefront/src/lib/conversion-events.ts +36 -0
- package/templates/storefront/src/lib/sanitize-auth-redirect.ts +16 -0
- package/templates/storefront/src/middleware.ts +25 -0
- package/templates/storefront/src/vendor/carrier/client.ts +95 -0
- package/templates/storefront/src/vendor/carrier/index.ts +2 -0
- package/templates/storefront/src/vendor/carrier/types.ts +21 -0
- package/templates/storefront/src/vendor/config/index.ts +2 -0
- package/templates/storefront/src/vendor/geo/index.ts +32 -0
- package/templates/storefront/src/vendor/ui/brand.ts +68 -0
- package/templates/storefront/src/vendor/ui/cn.ts +7 -0
- package/templates/storefront/src/vendor/ui/countryImagery.ts +46 -0
- package/templates/storefront/src/vendor/ui/index.ts +3 -0
- package/templates/storefront/tsconfig.json +23 -0
- package/templates/storefront/wrangler.jsonc +11 -0
- package/dist/.metadata_never_index +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* White-label brand configuration.
|
|
3
|
+
* Edit this file to re-skin the storefront for any brand.
|
|
4
|
+
*/
|
|
5
|
+
const brand = {
|
|
6
|
+
name: process.env.NEXT_PUBLIC_BRAND_NAME ?? "Carrier",
|
|
7
|
+
legalName: "Lifecycle Innovations Limited",
|
|
8
|
+
tagline: "Programmable connectivity, on demand.",
|
|
9
|
+
domain: "carrier.llc",
|
|
10
|
+
supportUrl: "https://carrier.llc/help",
|
|
11
|
+
supportEmail: "support@carrier.llc",
|
|
12
|
+
supportWhatsapp: "+17864604829",
|
|
13
|
+
colors: {
|
|
14
|
+
bg: "#080C16",
|
|
15
|
+
accent: "#FF6B35",
|
|
16
|
+
accentDark: "#D9461C",
|
|
17
|
+
accentLight: "#FFB088",
|
|
18
|
+
text: "#F5F1EA",
|
|
19
|
+
textSecondary: "#C9CCD6",
|
|
20
|
+
},
|
|
21
|
+
social: {
|
|
22
|
+
x: "@carrier_llc",
|
|
23
|
+
instagram: "@carrier.llc",
|
|
24
|
+
tiktok: "@carrier.llc",
|
|
25
|
+
},
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export default brand;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ClerkProvider } from "@clerk/nextjs";
|
|
4
|
+
import type { ComponentProps } from "react";
|
|
5
|
+
import { ThemeProvider } from "@/components/theme/theme-provider";
|
|
6
|
+
import { clerkAppearance } from "@/components/theme/clerk-appearance";
|
|
7
|
+
|
|
8
|
+
type ClerkAppearance = ComponentProps<typeof ClerkProvider>["appearance"];
|
|
9
|
+
|
|
10
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
11
|
+
return (
|
|
12
|
+
<ClerkProvider appearance={clerkAppearance as ClerkAppearance}>
|
|
13
|
+
<ThemeProvider>{children}</ThemeProvider>
|
|
14
|
+
</ClerkProvider>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
import { cn } from "@/vendor/ui/cn";
|
|
6
|
+
|
|
7
|
+
const FAQS = [
|
|
8
|
+
{
|
|
9
|
+
q: "What is an eSIM?",
|
|
10
|
+
a: "An eSIM (embedded SIM) is a digital SIM that lets you activate a mobile plan without a physical SIM card. You scan a QR code and your device is connected instantly.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
q: "Which devices are compatible?",
|
|
14
|
+
a: "Most modern smartphones, tablets, and smartwatches support eSIM — including iPhone XS and later, Samsung Galaxy S21+, Google Pixel 3a+, and many more.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
q: "How quickly does the eSIM activate?",
|
|
18
|
+
a: "Activation is immediate after purchase. You will receive a QR code by email within seconds. Scan it in your device settings and you are connected.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
q: "What happens when my data runs out?",
|
|
22
|
+
a: "You will receive a notification when you are running low. You can purchase a top-up or a new plan from the dashboard at any time.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
q: "Can I use my eSIM in multiple countries?",
|
|
26
|
+
a: "Yes — regional plans cover multiple countries. Check the plan details for the exact coverage area before purchasing.",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function FaqItem({ q, a }: { q: string; a: string }) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
return (
|
|
33
|
+
<div className="border-b border-white/10">
|
|
34
|
+
<button
|
|
35
|
+
className="flex w-full items-center justify-between py-4 text-left text-white hover:text-[var(--brand-accent)] transition-colors"
|
|
36
|
+
onClick={() => setOpen((o) => !o)}
|
|
37
|
+
>
|
|
38
|
+
<span className="font-medium">{q}</span>
|
|
39
|
+
<span
|
|
40
|
+
className={cn(
|
|
41
|
+
"ml-4 shrink-0 text-[var(--brand-accent)] transition-transform",
|
|
42
|
+
open && "rotate-45",
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
+
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
<AnimatePresence>
|
|
49
|
+
{open && (
|
|
50
|
+
<motion.div
|
|
51
|
+
initial={{ height: 0, opacity: 0 }}
|
|
52
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
53
|
+
exit={{ height: 0, opacity: 0 }}
|
|
54
|
+
transition={{ duration: 0.2 }}
|
|
55
|
+
className="overflow-hidden"
|
|
56
|
+
>
|
|
57
|
+
<p className="pb-4 text-sm text-white/60">{a}</p>
|
|
58
|
+
</motion.div>
|
|
59
|
+
)}
|
|
60
|
+
</AnimatePresence>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function FaqSection() {
|
|
66
|
+
return (
|
|
67
|
+
<section className="py-24 bg-[var(--brand-bg)]">
|
|
68
|
+
<div className="mx-auto max-w-2xl px-4">
|
|
69
|
+
<h2 className="text-3xl font-bold text-white text-center mb-12">
|
|
70
|
+
Frequently asked questions
|
|
71
|
+
</h2>
|
|
72
|
+
<div>
|
|
73
|
+
{FAQS.map((faq) => (
|
|
74
|
+
<FaqItem key={faq.q} q={faq.q} a={faq.a} />
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { BRAND } from "@/vendor/ui/brand";
|
|
3
|
+
|
|
4
|
+
const FOOTER_LINKS = {
|
|
5
|
+
Product: [
|
|
6
|
+
{ label: "Shop eSIMs", href: "/shop" },
|
|
7
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
8
|
+
{ label: "Help Center", href: "/help" },
|
|
9
|
+
],
|
|
10
|
+
Company: [
|
|
11
|
+
{ label: "Contact", href: "/contact" },
|
|
12
|
+
{ label: "Privacy", href: "/legal/privacy" },
|
|
13
|
+
{ label: "Terms", href: "/legal/terms" },
|
|
14
|
+
{ label: "Acceptable Use", href: "/legal/acceptable-use" },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function StorefrontFooter() {
|
|
19
|
+
return (
|
|
20
|
+
<footer className="border-t border-white/10 bg-[var(--brand-bg)]">
|
|
21
|
+
<div className="mx-auto max-w-6xl px-4 py-12">
|
|
22
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-10">
|
|
23
|
+
<div className="col-span-2">
|
|
24
|
+
<p className="font-semibold text-white mb-2">{BRAND.name}</p>
|
|
25
|
+
<p className="text-sm text-white/50 max-w-xs">{BRAND.tagline}</p>
|
|
26
|
+
</div>
|
|
27
|
+
{Object.entries(FOOTER_LINKS).map(([section, links]) => (
|
|
28
|
+
<div key={section}>
|
|
29
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-white/40 mb-3">
|
|
30
|
+
{section}
|
|
31
|
+
</p>
|
|
32
|
+
<ul className="space-y-2">
|
|
33
|
+
{links.map((link) => (
|
|
34
|
+
<li key={link.href}>
|
|
35
|
+
<Link
|
|
36
|
+
href={link.href}
|
|
37
|
+
className="text-sm text-white/60 hover:text-white transition-colors"
|
|
38
|
+
>
|
|
39
|
+
{link.label}
|
|
40
|
+
</Link>
|
|
41
|
+
</li>
|
|
42
|
+
))}
|
|
43
|
+
</ul>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="border-t border-white/10 pt-6 flex flex-col md:flex-row items-center justify-between gap-2">
|
|
48
|
+
<p className="text-xs text-white/40">
|
|
49
|
+
© {new Date().getFullYear()} {BRAND.legalName}. All rights reserved.
|
|
50
|
+
</p>
|
|
51
|
+
<a
|
|
52
|
+
href={`mailto:${BRAND.support.email}`}
|
|
53
|
+
className="text-xs text-white/40 hover:text-white transition-colors"
|
|
54
|
+
>
|
|
55
|
+
{BRAND.support.email}
|
|
56
|
+
</a>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</footer>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import brand from "@/brand.config";
|
|
6
|
+
|
|
7
|
+
export function HeroSection() {
|
|
8
|
+
return (
|
|
9
|
+
<section className="relative min-h-screen flex items-center justify-center overflow-hidden bg-[var(--brand-bg)]">
|
|
10
|
+
{/* Radial glow */}
|
|
11
|
+
<div
|
|
12
|
+
className="pointer-events-none absolute inset-0"
|
|
13
|
+
style={{
|
|
14
|
+
background: `radial-gradient(ellipse 80% 60% at 50% 0%, ${brand.colors.accent}22 0%, transparent 70%)`,
|
|
15
|
+
}}
|
|
16
|
+
/>
|
|
17
|
+
|
|
18
|
+
<div className="relative z-10 mx-auto max-w-4xl px-4 text-center">
|
|
19
|
+
<motion.div
|
|
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)]">
|
|
25
|
+
Global eSIM connectivity
|
|
26
|
+
</p>
|
|
27
|
+
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-white mb-6 leading-none">
|
|
28
|
+
Stay connected,{" "}
|
|
29
|
+
<span style={{ color: brand.colors.accent }}>everywhere.</span>
|
|
30
|
+
</h1>
|
|
31
|
+
<p className="text-lg md:text-xl text-white/60 mb-10 max-w-2xl mx-auto">
|
|
32
|
+
{brand.tagline} Instant digital SIM activation. No physical cards, no roaming fees.
|
|
33
|
+
</p>
|
|
34
|
+
<div className="flex flex-wrap gap-4 justify-center">
|
|
35
|
+
<Link
|
|
36
|
+
href="/shop"
|
|
37
|
+
className="rounded-full bg-[var(--brand-accent)] px-8 py-3 font-semibold text-white hover:bg-[var(--brand-accent-dark)] transition-colors"
|
|
38
|
+
>
|
|
39
|
+
Browse plans
|
|
40
|
+
</Link>
|
|
41
|
+
<Link
|
|
42
|
+
href="/help"
|
|
43
|
+
className="rounded-full border border-white/20 px-8 py-3 font-semibold text-white/80 hover:bg-white/5 transition-colors"
|
|
44
|
+
>
|
|
45
|
+
How it works
|
|
46
|
+
</Link>
|
|
47
|
+
</div>
|
|
48
|
+
</motion.div>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import { cn } from "@/vendor/ui/cn";
|
|
5
|
+
import type { SkuTemplate } from "@/vendor/carrier/types";
|
|
6
|
+
|
|
7
|
+
interface PlanCardProps {
|
|
8
|
+
plan: SkuTemplate;
|
|
9
|
+
onSelect?: (plan: SkuTemplate) => void;
|
|
10
|
+
featured?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PlanCard({ plan, onSelect, featured }: PlanCardProps) {
|
|
14
|
+
return (
|
|
15
|
+
<motion.div
|
|
16
|
+
whileHover={{ y: -4 }}
|
|
17
|
+
className={cn(
|
|
18
|
+
"relative rounded-2xl border p-6 flex flex-col gap-4 cursor-pointer transition-colors",
|
|
19
|
+
featured
|
|
20
|
+
? "border-[var(--brand-accent)] bg-[var(--brand-accent)]/5"
|
|
21
|
+
: "border-white/10 bg-white/5 hover:border-white/20",
|
|
22
|
+
)}
|
|
23
|
+
onClick={() => onSelect?.(plan)}
|
|
24
|
+
>
|
|
25
|
+
{featured && (
|
|
26
|
+
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[var(--brand-accent)] px-3 py-0.5 text-xs font-semibold text-white">
|
|
27
|
+
Most popular
|
|
28
|
+
</span>
|
|
29
|
+
)}
|
|
30
|
+
<div>
|
|
31
|
+
<p className="font-semibold text-white">{plan.name}</p>
|
|
32
|
+
<p className="text-sm text-white/50 mt-0.5">{plan.description}</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex items-baseline gap-1">
|
|
35
|
+
<span className="text-3xl font-bold text-white">
|
|
36
|
+
€{(plan.price_cents / 100).toFixed(2)}
|
|
37
|
+
</span>
|
|
38
|
+
<span className="text-sm text-white/40">one-time</span>
|
|
39
|
+
</div>
|
|
40
|
+
<ul className="space-y-1.5 text-sm text-white/70">
|
|
41
|
+
{plan.features.map((f, i) => (
|
|
42
|
+
<li key={i} className="flex items-center gap-2">
|
|
43
|
+
<span className="text-[var(--brand-accent)]">✓</span>
|
|
44
|
+
{f}
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
<button
|
|
49
|
+
className={cn(
|
|
50
|
+
"mt-auto rounded-full py-2 text-sm font-semibold transition-colors",
|
|
51
|
+
featured
|
|
52
|
+
? "bg-[var(--brand-accent)] text-white hover:bg-[var(--brand-accent-dark)]"
|
|
53
|
+
: "border border-white/20 text-white hover:bg-white/10",
|
|
54
|
+
)}
|
|
55
|
+
onClick={() => onSelect?.(plan)}
|
|
56
|
+
>
|
|
57
|
+
Select plan
|
|
58
|
+
</button>
|
|
59
|
+
</motion.div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
|
|
6
|
+
import { cn } from "@/vendor/ui/cn";
|
|
7
|
+
import brand from "@/brand.config";
|
|
8
|
+
|
|
9
|
+
const NAV_LINKS = [
|
|
10
|
+
{ label: "Shop", href: "/shop" },
|
|
11
|
+
{ label: "Help", href: "/help" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function StorefrontNav() {
|
|
15
|
+
const pathname = usePathname();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<header className="fixed top-0 inset-x-0 z-50 h-16 border-b border-white/10 bg-[var(--brand-bg)]/80 backdrop-blur-md">
|
|
19
|
+
<div className="mx-auto max-w-6xl h-full px-4 flex items-center justify-between">
|
|
20
|
+
<Link href="/" className="flex items-center gap-2 font-semibold text-white">
|
|
21
|
+
{brand.name}
|
|
22
|
+
</Link>
|
|
23
|
+
|
|
24
|
+
<nav className="hidden md:flex items-center gap-6">
|
|
25
|
+
{NAV_LINKS.map((link) => (
|
|
26
|
+
<Link
|
|
27
|
+
key={link.href}
|
|
28
|
+
href={link.href}
|
|
29
|
+
className={cn(
|
|
30
|
+
"text-sm transition-colors",
|
|
31
|
+
pathname?.startsWith(link.href)
|
|
32
|
+
? "text-[var(--brand-accent)]"
|
|
33
|
+
: "text-white/70 hover:text-white",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{link.label}
|
|
37
|
+
</Link>
|
|
38
|
+
))}
|
|
39
|
+
</nav>
|
|
40
|
+
|
|
41
|
+
<div className="flex items-center gap-3">
|
|
42
|
+
<SignedOut>
|
|
43
|
+
<Link
|
|
44
|
+
href="/sign-in"
|
|
45
|
+
className="text-sm text-white/70 hover:text-white transition-colors"
|
|
46
|
+
>
|
|
47
|
+
Sign in
|
|
48
|
+
</Link>
|
|
49
|
+
<Link
|
|
50
|
+
href="/shop"
|
|
51
|
+
className="rounded-full bg-[var(--brand-accent)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--brand-accent-dark)] transition-colors"
|
|
52
|
+
>
|
|
53
|
+
Get eSIM
|
|
54
|
+
</Link>
|
|
55
|
+
</SignedOut>
|
|
56
|
+
<SignedIn>
|
|
57
|
+
<Link
|
|
58
|
+
href="/dashboard"
|
|
59
|
+
className="text-sm text-white/70 hover:text-white transition-colors"
|
|
60
|
+
>
|
|
61
|
+
Dashboard
|
|
62
|
+
</Link>
|
|
63
|
+
<UserButton afterSignOutUrl="/" />
|
|
64
|
+
</SignedIn>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Inline brand colors — no external package dependency
|
|
2
|
+
const colors = {
|
|
3
|
+
bg: "#080C16",
|
|
4
|
+
accent: "#FF6B35",
|
|
5
|
+
accentDark: "#D9461C",
|
|
6
|
+
text: "#F5F1EA",
|
|
7
|
+
textSecondary: "#C9CCD6",
|
|
8
|
+
surface: "#111827",
|
|
9
|
+
border: "#1F2937",
|
|
10
|
+
};
|
|
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
|
+
export const clerkAppearance: object = {
|
|
15
|
+
variables: {
|
|
16
|
+
colorPrimary: colors.accent,
|
|
17
|
+
colorBackground: colors.surface,
|
|
18
|
+
colorInputBackground: colors.bg,
|
|
19
|
+
colorText: colors.text,
|
|
20
|
+
colorTextSecondary: colors.textSecondary,
|
|
21
|
+
colorInputText: colors.text,
|
|
22
|
+
colorNeutral: colors.textSecondary,
|
|
23
|
+
borderRadius: "0.5rem",
|
|
24
|
+
fontFamily: "var(--font-geist-sans, system-ui, sans-serif)",
|
|
25
|
+
},
|
|
26
|
+
elements: {
|
|
27
|
+
card: {
|
|
28
|
+
backgroundColor: colors.surface,
|
|
29
|
+
border: `1px solid ${colors.border}`,
|
|
30
|
+
boxShadow: "none",
|
|
31
|
+
},
|
|
32
|
+
headerTitle: { color: colors.text },
|
|
33
|
+
headerSubtitle: { color: colors.textSecondary },
|
|
34
|
+
socialButtonsBlockButton: {
|
|
35
|
+
backgroundColor: colors.bg,
|
|
36
|
+
border: `1px solid ${colors.border}`,
|
|
37
|
+
color: colors.text,
|
|
38
|
+
},
|
|
39
|
+
socialButtonsBlockButtonText: { color: colors.text },
|
|
40
|
+
dividerLine: { backgroundColor: colors.border },
|
|
41
|
+
dividerText: { color: colors.textSecondary },
|
|
42
|
+
formFieldLabel: { color: colors.textSecondary },
|
|
43
|
+
formFieldInput: {
|
|
44
|
+
backgroundColor: colors.bg,
|
|
45
|
+
border: `1px solid ${colors.border}`,
|
|
46
|
+
color: colors.text,
|
|
47
|
+
},
|
|
48
|
+
footerActionLink: { color: colors.accent },
|
|
49
|
+
identityPreviewEditButton: { color: colors.accent },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
export type Theme = "light" | "dark";
|
|
6
|
+
export type ThemePreference = Theme | "system";
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "carrier-theme";
|
|
9
|
+
|
|
10
|
+
type ThemeContextValue = {
|
|
11
|
+
theme: Theme;
|
|
12
|
+
preference: ThemePreference;
|
|
13
|
+
setPreference: (pref: ThemePreference) => void;
|
|
14
|
+
toggle: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
|
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
|
+
function resolve(pref: ThemePreference): Theme {
|
|
25
|
+
return pref === "system" ? systemTheme() : pref;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function applyToDocument(theme: Theme) {
|
|
29
|
+
const root = document.documentElement;
|
|
30
|
+
root.setAttribute("data-theme", theme);
|
|
31
|
+
root.style.colorScheme = theme;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
35
|
+
const [preference, setPreferenceState] = React.useState<ThemePreference>(() => {
|
|
36
|
+
if (typeof window === "undefined") return "dark";
|
|
37
|
+
try {
|
|
38
|
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
39
|
+
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
|
40
|
+
} catch { /* private mode */ }
|
|
41
|
+
return "system";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const [theme, setTheme] = React.useState<Theme>(() => {
|
|
45
|
+
if (typeof window === "undefined") return "dark";
|
|
46
|
+
const attr = document.documentElement.getAttribute("data-theme");
|
|
47
|
+
if (attr === "light" || attr === "dark") return attr;
|
|
48
|
+
return resolve(preference);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const setPreference = React.useCallback((pref: ThemePreference) => {
|
|
52
|
+
setPreferenceState(pref);
|
|
53
|
+
const next = resolve(pref);
|
|
54
|
+
setTheme(next);
|
|
55
|
+
applyToDocument(next);
|
|
56
|
+
try { window.localStorage.setItem(STORAGE_KEY, pref); } catch { /* ignore */ }
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const toggle = React.useCallback(() => {
|
|
60
|
+
setPreference(theme === "dark" ? "light" : "dark");
|
|
61
|
+
}, [theme, setPreference]);
|
|
62
|
+
|
|
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
|
+
const value = React.useMemo<ThemeContextValue>(
|
|
76
|
+
() => ({ theme, preference, setPreference, toggle }),
|
|
77
|
+
[theme, preference, setPreference, toggle],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useTheme(): ThemeContextValue {
|
|
84
|
+
const ctx = React.useContext(ThemeContext);
|
|
85
|
+
if (!ctx) throw new Error("useTheme must be used within <ThemeProvider>");
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const STORAGE_KEY = "carrier-theme";
|
|
2
|
+
|
|
3
|
+
const themeScript = `
|
|
4
|
+
(function () {
|
|
5
|
+
try {
|
|
6
|
+
var pref = localStorage.getItem("${STORAGE_KEY}");
|
|
7
|
+
var theme;
|
|
8
|
+
if (pref === "light" || pref === "dark") {
|
|
9
|
+
theme = pref;
|
|
10
|
+
} else {
|
|
11
|
+
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
12
|
+
}
|
|
13
|
+
var root = document.documentElement;
|
|
14
|
+
root.setAttribute("data-theme", theme);
|
|
15
|
+
root.style.colorScheme = theme;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function ThemeScript() {
|
|
23
|
+
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
|
24
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
gtag?: (...args: any[]) => void;
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
fbq?: (...args: any[]) => void;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function gtag(event: string, params?: Record<string, unknown>): void {
|
|
11
|
+
if (typeof window !== "undefined" && typeof window.gtag === "function") {
|
|
12
|
+
window.gtag("event", event, params ?? {});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fbq(event: string, data?: Record<string, unknown>): void {
|
|
17
|
+
if (typeof window !== "undefined" && typeof window.fbq === "function") {
|
|
18
|
+
window.fbq("track", event, data ?? {});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const conversionEvents = {
|
|
23
|
+
beginCheckout: (planName: string, value: number, currency = "EUR") => {
|
|
24
|
+
gtag("begin_checkout", { items: [{ item_name: planName }], value, currency });
|
|
25
|
+
fbq("InitiateCheckout", { content_name: planName, value, currency });
|
|
26
|
+
},
|
|
27
|
+
purchase: (planName: string, value: number, transactionId?: string) => {
|
|
28
|
+
const id = transactionId ?? crypto.randomUUID();
|
|
29
|
+
gtag("purchase", { transaction_id: id, value, currency: "EUR", items: [{ item_name: planName }] });
|
|
30
|
+
fbq("Purchase", { content_name: planName, value, currency: "EUR" });
|
|
31
|
+
},
|
|
32
|
+
selectPlan: (planName: string) => {
|
|
33
|
+
gtag("select_item", { items: [{ item_name: planName }] });
|
|
34
|
+
fbq("AddToCart", { content_name: planName });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const ALLOWED_PATHS = /^\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/;
|
|
2
|
+
|
|
3
|
+
export function sanitizeAuthRedirect(
|
|
4
|
+
rawRedirect: string | null | undefined,
|
|
5
|
+
fallback = "/dashboard",
|
|
6
|
+
): string {
|
|
7
|
+
if (!rawRedirect) return fallback;
|
|
8
|
+
try {
|
|
9
|
+
// Reject absolute URLs and protocol-relative
|
|
10
|
+
if (rawRedirect.startsWith("//") || rawRedirect.includes(":")) return fallback;
|
|
11
|
+
if (!ALLOWED_PATHS.test(rawRedirect)) return fallback;
|
|
12
|
+
return rawRedirect;
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
|
2
|
+
|
|
3
|
+
const isPublicRoute = createRouteMatcher([
|
|
4
|
+
"/",
|
|
5
|
+
"/shop(.*)",
|
|
6
|
+
"/legal(.*)",
|
|
7
|
+
"/contact",
|
|
8
|
+
"/help",
|
|
9
|
+
"/sign-in(.*)",
|
|
10
|
+
"/sign-up(.*)",
|
|
11
|
+
"/api/health",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export default clerkMiddleware(async (auth, req) => {
|
|
15
|
+
if (!isPublicRoute(req)) {
|
|
16
|
+
await auth.protect();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const config = {
|
|
21
|
+
matcher: [
|
|
22
|
+
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
|
23
|
+
"/(api|trpc)(.*)",
|
|
24
|
+
],
|
|
25
|
+
};
|