@carrierllc/mcp 0.2.15 → 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.
Files changed (79) hide show
  1. package/dist/cli.js +644 -0
  2. package/dist/cli.js.map +1 -0
  3. package/dist/index.js +1264 -243
  4. package/dist/index.js.map +1 -1
  5. package/package.json +21 -14
  6. package/plugin/.claude-plugin/marketplace.json +31 -0
  7. package/plugin/carrier/.claude-plugin/plugin.json +19 -0
  8. package/plugin/carrier/.mcp.json +8 -0
  9. package/plugin/carrier/README.md +48 -0
  10. package/plugin/carrier/agents/carrier-billing-auditor.md +16 -0
  11. package/plugin/carrier/agents/carrier-fleet-ops.md +15 -0
  12. package/plugin/carrier/agents/carrier-storefront-builder.md +17 -0
  13. package/plugin/carrier/commands/billing.md +16 -0
  14. package/plugin/carrier/commands/churn.md +15 -0
  15. package/plugin/carrier/commands/credits.md +15 -0
  16. package/plugin/carrier/commands/esim-status.md +15 -0
  17. package/plugin/carrier/commands/fleet.md +17 -0
  18. package/plugin/carrier/commands/greenzone.md +16 -0
  19. package/plugin/carrier/commands/onboard.md +17 -0
  20. package/plugin/carrier/commands/packages.md +18 -0
  21. package/plugin/carrier/commands/provision.md +20 -0
  22. package/plugin/carrier/commands/sms.md +18 -0
  23. package/plugin/carrier/commands/status.md +15 -0
  24. package/plugin/carrier/commands/storefront.md +21 -0
  25. package/plugin/carrier/commands/subscribers.md +18 -0
  26. package/plugin/carrier/commands/usage.md +16 -0
  27. package/plugin/carrier/skills/carrier-operations/SKILL.md +36 -0
  28. package/templates/storefront/eslint.config.mjs +15 -0
  29. package/templates/storefront/next.config.ts +22 -0
  30. package/templates/storefront/open-next.config.ts +3 -0
  31. package/templates/storefront/package.json +38 -0
  32. package/templates/storefront/pnpm-lock.yaml +4020 -0
  33. package/templates/storefront/postcss.config.mjs +7 -0
  34. package/templates/storefront/src/app/activate/[orderId]/page.tsx +28 -0
  35. package/templates/storefront/src/app/apple-icon.tsx +29 -0
  36. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +52 -0
  37. package/templates/storefront/src/app/checkout/[templateId]/page.tsx +24 -0
  38. package/templates/storefront/src/app/contact/page.tsx +35 -0
  39. package/templates/storefront/src/app/dashboard/page.tsx +26 -0
  40. package/templates/storefront/src/app/globals.css +99 -0
  41. package/templates/storefront/src/app/help/page.tsx +25 -0
  42. package/templates/storefront/src/app/icon.tsx +29 -0
  43. package/templates/storefront/src/app/layout.tsx +56 -0
  44. package/templates/storefront/src/app/legal/acceptable-use/page.tsx +21 -0
  45. package/templates/storefront/src/app/legal/privacy/page.tsx +27 -0
  46. package/templates/storefront/src/app/legal/terms/page.tsx +25 -0
  47. package/templates/storefront/src/app/manifest.ts +18 -0
  48. package/templates/storefront/src/app/page.tsx +31 -0
  49. package/templates/storefront/src/app/robots.ts +9 -0
  50. package/templates/storefront/src/app/shop/ShopClient.tsx +27 -0
  51. package/templates/storefront/src/app/shop/page.tsx +9 -0
  52. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +24 -0
  53. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +13 -0
  54. package/templates/storefront/src/app/sitemap.ts +14 -0
  55. package/templates/storefront/src/brand.config.ts +28 -0
  56. package/templates/storefront/src/components/Providers.tsx +16 -0
  57. package/templates/storefront/src/components/faq/FaqSection.tsx +80 -0
  58. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +61 -0
  59. package/templates/storefront/src/components/landing/HeroSection.tsx +52 -0
  60. package/templates/storefront/src/components/landing/PlanCard.tsx +61 -0
  61. package/templates/storefront/src/components/nav/StorefrontNav.tsx +69 -0
  62. package/templates/storefront/src/components/theme/clerk-appearance.ts +51 -0
  63. package/templates/storefront/src/components/theme/theme-provider.tsx +87 -0
  64. package/templates/storefront/src/components/theme/theme-script.tsx +24 -0
  65. package/templates/storefront/src/lib/conversion-events.ts +36 -0
  66. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +16 -0
  67. package/templates/storefront/src/middleware.ts +25 -0
  68. package/templates/storefront/src/vendor/carrier/client.ts +95 -0
  69. package/templates/storefront/src/vendor/carrier/index.ts +2 -0
  70. package/templates/storefront/src/vendor/carrier/types.ts +21 -0
  71. package/templates/storefront/src/vendor/config/index.ts +2 -0
  72. package/templates/storefront/src/vendor/geo/index.ts +32 -0
  73. package/templates/storefront/src/vendor/ui/brand.ts +68 -0
  74. package/templates/storefront/src/vendor/ui/cn.ts +7 -0
  75. package/templates/storefront/src/vendor/ui/countryImagery.ts +46 -0
  76. package/templates/storefront/src/vendor/ui/index.ts +3 -0
  77. package/templates/storefront/tsconfig.json +23 -0
  78. package/templates/storefront/wrangler.jsonc +11 -0
  79. 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
+ &copy; {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
+ };