@carrierllc/mcp 0.2.16 → 0.2.18
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 +21 -1
- package/dist/cli.js +1445 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +1437 -252
- package/dist/index.js.map +1 -1
- package/package.json +23 -15
- 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 +75 -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 +23 -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 +30 -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/commands/wallet.md +31 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +43 -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]/ActivateClient.tsx +125 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +32 -0
- package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
- package/templates/storefront/src/app/apple-icon.tsx +29 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +110 -0
- package/templates/storefront/src/app/checkout/[templateId]/page.tsx +24 -0
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +59 -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]]/StorefrontSignUpClient.tsx +199 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +28 -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/checkout-order-claim.ts +127 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -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/lib/verify-checkout-session.ts +53 -0
- package/templates/storefront/src/middleware.ts +31 -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,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,127 @@
|
|
|
1
|
+
import { clerkClient } from "@clerk/nextjs/server";
|
|
2
|
+
import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
|
|
3
|
+
|
|
4
|
+
type StripeSession = {
|
|
5
|
+
metadata?: Record<string, string>;
|
|
6
|
+
customer_email?: string | null;
|
|
7
|
+
customer_details?: { email?: string | null };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
async function fetchStripeSession(sessionId: string): Promise<StripeSession | null> {
|
|
11
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
12
|
+
if (!stripeKey) return null;
|
|
13
|
+
|
|
14
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}`, {
|
|
15
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
16
|
+
});
|
|
17
|
+
if (!resp.ok) return null;
|
|
18
|
+
return (await resp.json()) as StripeSession;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function setStripeClaimedBy(sessionId: string, userId: string): Promise<boolean> {
|
|
22
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
23
|
+
if (!stripeKey) return false;
|
|
24
|
+
|
|
25
|
+
const formBody = new URLSearchParams({
|
|
26
|
+
"metadata[claimedByUserId]": userId,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
33
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
34
|
+
},
|
|
35
|
+
body: formBody.toString(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return resp.ok;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function claimedOrderIds(metadata: Record<string, unknown> | undefined): string[] {
|
|
42
|
+
const value = metadata?.claimedOrderIds;
|
|
43
|
+
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sessionPurchaserEmail(session: StripeSession): string | null {
|
|
47
|
+
return session.customer_details?.email ?? session.customer_email ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function userMatchesSessionPurchaser(session: StripeSession, userId: string): Promise<boolean> {
|
|
51
|
+
const purchaserEmail = sessionPurchaserEmail(session);
|
|
52
|
+
if (!purchaserEmail) return true;
|
|
53
|
+
|
|
54
|
+
const client = await clerkClient();
|
|
55
|
+
const user = await client.users.getUser(userId);
|
|
56
|
+
const normalizedPurchaserEmail = purchaserEmail.toLowerCase();
|
|
57
|
+
return user.emailAddresses.some(
|
|
58
|
+
(address) => address.emailAddress.toLowerCase() === normalizedPurchaserEmail,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function addClaimedOrderToUser(userId: string, orderId: string): Promise<void> {
|
|
63
|
+
const client = await clerkClient();
|
|
64
|
+
const user = await client.users.getUser(userId);
|
|
65
|
+
const existing = claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined);
|
|
66
|
+
if (existing.includes(orderId)) return;
|
|
67
|
+
|
|
68
|
+
await client.users.updateUserMetadata(userId, {
|
|
69
|
+
privateMetadata: { claimedOrderIds: [...existing, orderId] },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function claimCheckoutOrder(
|
|
74
|
+
userId: string,
|
|
75
|
+
sessionId: string,
|
|
76
|
+
templateId: string,
|
|
77
|
+
): Promise<{ ok: true; orderId: string } | { ok: false; status: number; error: string }> {
|
|
78
|
+
const verification = await verifyCheckoutSession(sessionId, templateId);
|
|
79
|
+
if (!verification.ok) {
|
|
80
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const orderId = verification.orderId;
|
|
84
|
+
|
|
85
|
+
if (sessionId !== "mock" && process.env.STRIPE_SECRET_KEY) {
|
|
86
|
+
const session = await fetchStripeSession(sessionId);
|
|
87
|
+
if (!session) {
|
|
88
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!(await userMatchesSessionPurchaser(session, userId))) {
|
|
92
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const claimedBy = session.metadata?.claimedByUserId;
|
|
96
|
+
if (claimedBy && claimedBy !== userId) {
|
|
97
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
98
|
+
}
|
|
99
|
+
if (!claimedBy) {
|
|
100
|
+
const updated = await setStripeClaimedBy(sessionId, userId);
|
|
101
|
+
if (!updated) {
|
|
102
|
+
return { ok: false, status: 502, error: "Failed to claim order" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const afterClaim = await fetchStripeSession(sessionId);
|
|
106
|
+
if (afterClaim?.metadata?.claimedByUserId !== userId) {
|
|
107
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await addClaimedOrderToUser(userId, orderId);
|
|
113
|
+
return { ok: true, orderId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function userOwnsOrder(userId: string, orderId: string): Promise<boolean> {
|
|
117
|
+
if (process.env.STRIPE_SECRET_KEY && orderId.startsWith("cs_")) {
|
|
118
|
+
const session = await fetchStripeSession(orderId);
|
|
119
|
+
return session?.metadata?.claimedByUserId === userId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const client = await clerkClient();
|
|
123
|
+
const user = await client.users.getUser(userId);
|
|
124
|
+
return claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined).includes(
|
|
125
|
+
orderId,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type SignUpVerificationResult = {
|
|
2
|
+
status: string;
|
|
3
|
+
createdSessionId: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type SignUpForEmailVerify = {
|
|
7
|
+
missingFields?: string[];
|
|
8
|
+
unverifiedFields?: string[];
|
|
9
|
+
update: (params: Record<string, never>) => Promise<SignUpVerificationResult>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EmailSignUpCompletion =
|
|
13
|
+
| { status: "complete"; sessionId: string }
|
|
14
|
+
| { status: "incomplete"; message: string };
|
|
15
|
+
|
|
16
|
+
export async function completeEmailSignUpAfterVerify(
|
|
17
|
+
signUp: SignUpForEmailVerify,
|
|
18
|
+
result: SignUpVerificationResult,
|
|
19
|
+
): Promise<EmailSignUpCompletion> {
|
|
20
|
+
if (result.status === "complete" && result.createdSessionId) {
|
|
21
|
+
return { status: "complete", sessionId: result.createdSessionId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (result.status === "missing_requirements") {
|
|
25
|
+
const missingFields = signUp.missingFields ?? [];
|
|
26
|
+
const unverifiedFields = signUp.unverifiedFields ?? [];
|
|
27
|
+
const onlyPhoneMissing =
|
|
28
|
+
missingFields.length === 1 &&
|
|
29
|
+
missingFields[0] === "phone_number" &&
|
|
30
|
+
unverifiedFields.length === 0;
|
|
31
|
+
|
|
32
|
+
if (onlyPhoneMissing) {
|
|
33
|
+
try {
|
|
34
|
+
const updated = await signUp.update({});
|
|
35
|
+
if (updated.status === "complete" && updated.createdSessionId) {
|
|
36
|
+
return { status: "complete", sessionId: updated.createdSessionId };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Fall through to generic incomplete message.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
status: "incomplete",
|
|
45
|
+
message: "One more step required. Please complete your profile to continue.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { status: "incomplete", message: "Verification incomplete. Please try again." };
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|