fix stripe configuration
This commit is contained in:
79
web/src/components/EmbeddedCheckout.tsx
Normal file
79
web/src/components/EmbeddedCheckout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import type { Stripe, StripeEmbeddedCheckout } from "@stripe/stripe-js";
|
||||
|
||||
interface EmbeddedCheckoutProps {
|
||||
clientSecret: string;
|
||||
onCheckoutComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function EmbeddedCheckout(props: EmbeddedCheckoutProps) {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
let embeddedCheckout: StripeEmbeddedCheckout | null = null;
|
||||
|
||||
try {
|
||||
const mod = await import("@stripe/stripe-js");
|
||||
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!publishableKey) {
|
||||
setError("Stripe publishable key not configured");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stripe = await mod.loadStripe(publishableKey);
|
||||
if (!stripe) {
|
||||
setError("Failed to load Stripe");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
embeddedCheckout = await stripe.createEmbeddedCheckoutPage({
|
||||
clientSecret: props.clientSecret,
|
||||
onComplete: () => {
|
||||
props.onCheckoutComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (container) {
|
||||
embeddedCheckout.mount(container);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load checkout");
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
embeddedCheckout?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center min-h-[400px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Loading checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error() && !loading()}>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-[var(--color-error)] mb-4">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={container}
|
||||
class={loading() ? "hidden" : ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function CTABannerSection(props: CTABannerSectionProps) {
|
||||
return (
|
||||
<section id="cta" class={cn("py-20 md:py-28 scroll-mt-16", props.class)}>
|
||||
<PageContainer py="py-8">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Create Account
|
||||
</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">
|
||||
Sign In
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface Feature {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function DarkWatchIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoicePrintIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 4a4 4 0 00-4 4v8a4 4 0 008 0V8a4 4 0 00-4-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 11v2m16-2v2M8 18.5A6 6 0 0016 18.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpamShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 10l4 4m0-4l-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeTitleIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveBrokersIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2m-4 4v6m-4-6v6m-3-8v10a2 2 0 002 2h10a2 2 0 002-2V8H8z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyPlansIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 12a4 4 0 100-8 4 4 0 000 8zM5 22v-2a4 4 0 014-4h6a4 4 0 014 4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 9a3 3 0 100-6 3 3 0 000 6zM16 22v-2a3 3 0 00-3-3h-2a3 3 0 00-3 3v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
title: "DarkWatch",
|
||||
description:
|
||||
"Continuous dark web monitoring to detect your exposed credentials and personal data.",
|
||||
icon: DarkWatchIcon,
|
||||
},
|
||||
{
|
||||
title: "VoicePrint",
|
||||
description:
|
||||
"AI-powered voice clone detection to protect against deepfake voice scams.",
|
||||
icon: VoicePrintIcon,
|
||||
},
|
||||
{
|
||||
title: "SpamShield",
|
||||
description:
|
||||
"Intelligent spam and scam call blocking that learns your patterns over time.",
|
||||
icon: SpamShieldIcon,
|
||||
},
|
||||
{
|
||||
title: "HomeTitle",
|
||||
description:
|
||||
"Property fraud alerts that notify you of unauthorized changes to your home records.",
|
||||
icon: HomeTitleIcon,
|
||||
},
|
||||
{
|
||||
title: "RemoveBrokers",
|
||||
description:
|
||||
"Automatic data broker removal to minimize your personal data footprint online.",
|
||||
icon: RemoveBrokersIcon,
|
||||
},
|
||||
{
|
||||
title: "Family Plans",
|
||||
description:
|
||||
"Protect your whole household with shared monitoring, alerts, and management tools.",
|
||||
icon: FamilyPlansIcon,
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
function FeatureCard(props: FeatureCardProps) {
|
||||
const Icon = props.feature.icon;
|
||||
return (
|
||||
<Card class="hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 p-2 rounded-lg bg-[var(--color-bg-secondary)]">
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{props.feature.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{props.feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeaturesGridSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function FeaturesGridSection(props: FeaturesGridSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="features"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Platform Features
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Comprehensive protection powered by AI and real-time monitoring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={features}>
|
||||
{(feature) => <FeatureCard feature={feature} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 9l3 3 7-7"
|
||||
stroke="var(--color-success)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IndividualIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<circle cx="20" cy="14" r="6" fill="currentColor" />
|
||||
<path
|
||||
d="M8 32c0-6.6 5.4-12 12-12s12 5.4 12 12H8z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<circle cx="14" cy="12" r="5" fill="currentColor" />
|
||||
<circle cx="26" cy="12" r="4" fill="currentColor" opacity="0.7" />
|
||||
<path
|
||||
d="M2 30c0-5 4-9 9-9 1.5 0 3 .4 4.2 1.1C16.5 21.5 18 21 20 21s3.5.5 4.8 1.1C26 21.4 27.5 21 29 21c5 0 9 4 9 9H2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function Panel(props: PanelProps) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<Card class="h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4">
|
||||
<Icon />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
{props.description}
|
||||
</p>
|
||||
<ul class="space-y-3 flex-1">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">
|
||||
{item}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForUsersSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function ForUsersSection(props: ForUsersSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="for-users"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
For Everyone
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Whether you're protecting yourself or your whole family
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Panel
|
||||
title="For Individuals"
|
||||
description="Personal identity protection tailored to your digital footprint."
|
||||
icon={IndividualIcon}
|
||||
items={[
|
||||
"Monitor personal email and phone numbers",
|
||||
"Dark web credential scanning",
|
||||
"Voiceprint clone detection",
|
||||
"Spam and scam call filtering",
|
||||
"Data broker opt-out service",
|
||||
]}
|
||||
/>
|
||||
<Panel
|
||||
title="For Families"
|
||||
description="Group management tools to keep every household member safe."
|
||||
icon={FamilyIcon}
|
||||
items={[
|
||||
"Add unlimited family members",
|
||||
"Shared alert dashboard",
|
||||
"Child account monitoring",
|
||||
"Family-wide dark web scans",
|
||||
"Centralized threat notifications",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { onMount } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import { Typewriter } from "~/components/ui/Typewriter";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-12 h-12 md:w-16 md:h-16"
|
||||
>
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path
|
||||
d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z"
|
||||
fill="white"
|
||||
fill-opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M20 24l3 2.5 5-5"
|
||||
stroke="var(--color-brand-primary)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeroSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function HeroSection(props: HeroSectionProps) {
|
||||
let heroRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (heroRef) {
|
||||
heroRef.style.opacity = "1";
|
||||
heroRef.style.transform = "translateY(0)";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class={cn("relative", props.class)}>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<ShieldIcon />
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Get Started
|
||||
</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">
|
||||
Learn More
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface Step {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function EnrollIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M12 4a4 4 0 100 8 4 4 0 000-8zM6 21v-2a4 4 0 014-4h4a4 4 0 014 4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 8l2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12 8v4l3 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h3m15 0h3M12 3v3m0 15v3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 22a2 2 0 01-2-2h4a2 2 0 01-2 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12 11v3m0-6v1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Enroll Your Identity",
|
||||
description:
|
||||
"Sign up and add your emails, phone numbers, and family members to create your protection profile.",
|
||||
icon: EnrollIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "We Monitor 24/7",
|
||||
description:
|
||||
"Our system runs continuous dark web scans, voiceprint detection, and spam filtering to catch threats early.",
|
||||
icon: MonitorIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Get Instant Alerts",
|
||||
description:
|
||||
"Receive real-time notifications the moment a threat is detected, with clear guidance on what to do next.",
|
||||
icon: AlertIcon,
|
||||
},
|
||||
];
|
||||
|
||||
interface StepBlockProps {
|
||||
step: Step;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function StepBlock(props: StepBlockProps) {
|
||||
const isEven = props.index % 2 === 0;
|
||||
const Icon = props.step.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex gap-8 md:flex-row flex-col",
|
||||
isEven ? "" : "md:flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-5">
|
||||
<div class="w-14 h-14 rounded-full gradient-primary shadow-glow-primary flex items-center justify-center shrink-0">
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 mb-1.5">
|
||||
<span class="text-sm font-semibold text-[var(--color-brand-primary)]">
|
||||
Step {props.step.number}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-xl md:text-2xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
{props.step.title}
|
||||
</h3>
|
||||
<p class="text-base text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{props.step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 hidden md:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HowItWorksSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function HowItWorksSection(props: HowItWorksSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Three simple steps to full identity protection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-12 md:gap-16">
|
||||
<For each={steps}>
|
||||
{(step, index) => <StepBlock step={step} index={index()} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 9l3 3 7-7"
|
||||
stroke="var(--color-success)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ProactiveIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M13 3l-2 6h5l-3 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 14l5-5m0 0l5 5m-5-5v12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AIIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 16l2 2-2 2M16 16l-2 2 2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12l2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
const valueProps: ValueProp[] = [
|
||||
{
|
||||
title: "Proactive, Not Reactive",
|
||||
description:
|
||||
"We detect threats before they cause damage, so you can act early.",
|
||||
icon: ProactiveIcon,
|
||||
items: [
|
||||
"Real-time dark web scanning",
|
||||
"Pre-breach alerts and warnings",
|
||||
"Automated threat response",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI-Powered Detection",
|
||||
description:
|
||||
"Machine learning models trained on real scam data to catch the latest threats.",
|
||||
icon: AIIcon,
|
||||
items: [
|
||||
"Deepfake voice identification",
|
||||
"Pattern-based scam detection",
|
||||
"Continuous model improvement",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Privacy First",
|
||||
description:
|
||||
"Your data stays encrypted and private. We never sell your information.",
|
||||
icon: PrivacyIcon,
|
||||
items: [
|
||||
"End-to-end encrypted data",
|
||||
"GDPR and CCPA compliant",
|
||||
"Zero data selling policy",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface ValueCardProps {
|
||||
prop: ValueProp;
|
||||
}
|
||||
|
||||
function ValueCard(props: ValueCardProps) {
|
||||
const Icon = props.prop.icon;
|
||||
return (
|
||||
<Card class="backdrop-blur-2xl">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-3 p-2 rounded-lg bg-[var(--color-bg-secondary)] w-fit">
|
||||
<Icon />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{props.prop.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-4 leading-relaxed">
|
||||
{props.prop.description}
|
||||
</p>
|
||||
<ul class="space-y-2 flex-1">
|
||||
<For each={props.prop.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">
|
||||
{item}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface WhyKordantSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function WhyKordantSection(props: WhyKordantSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="why-kordant"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Why Kordant
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Built on cutting-edge technology with your privacy at the core
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<For each={valueProps}>
|
||||
{(prop) => <ValueCard prop={prop} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||
const href = props.href || "#";
|
||||
return (
|
||||
<a href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import HeroSection from "./HeroSection";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HeroSection", () => {
|
||||
it("renders the headline with AI-Powered Identity Protection", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("AI-Powered");
|
||||
expect(document.body.textContent).toContain("Identity Protection");
|
||||
expect(document.body.textContent).toContain("for Everyone");
|
||||
});
|
||||
|
||||
it("renders the subheadline", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Kordant uses advanced AI");
|
||||
});
|
||||
|
||||
it("renders the Get Started CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Get Started");
|
||||
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||
expect(primaryBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Learn More CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("renders trust indicators", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("No credit card required");
|
||||
expect(document.body.textContent).toContain("Free tier available");
|
||||
});
|
||||
|
||||
it("renders the shield icon SVG", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders two buttons for CTAs", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has Get Started button wrapped in link to /signup", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const signupLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/signup",
|
||||
);
|
||||
expect(signupLink).toBeTruthy();
|
||||
expect(signupLink!.textContent).toContain("Get Started");
|
||||
});
|
||||
|
||||
it("has Learn More button wrapped in link to #features", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const featuresLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "#features",
|
||||
);
|
||||
expect(featuresLink).toBeTruthy();
|
||||
expect(featuresLink!.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <HeroSection class="custom-hero" />);
|
||||
const section = document.querySelector("section.custom-hero");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has centered text layout", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".text-center");
|
||||
expect(inner).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has responsive vertical padding", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".py-20");
|
||||
expect(inner).toBeTruthy();
|
||||
expect(inner!.className).toContain("md:py-32");
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||
const href = props.href || "#";
|
||||
return (
|
||||
<a href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import HowItWorksSection from "./HowItWorksSection";
|
||||
import FeaturesGridSection from "./FeaturesGridSection";
|
||||
import ForUsersSection from "./ForUsersSection";
|
||||
import WhyKordantSection from "./WhyKordantSection";
|
||||
import CTABannerSection from "./CTABannerSection";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HowItWorksSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain("How It Works");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Three simple steps to full identity protection",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders all 3 steps", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain("Enroll Your Identity");
|
||||
expect(document.body.textContent).toContain("We Monitor 24/7");
|
||||
expect(document.body.textContent).toContain("Get Instant Alerts");
|
||||
});
|
||||
|
||||
it("renders step descriptions", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Sign up and add your emails",
|
||||
);
|
||||
expect(document.body.textContent).toContain("dark web scans");
|
||||
expect(document.body.textContent).toContain("real-time notifications");
|
||||
});
|
||||
|
||||
it("renders 3 numbered circles with gradient-primary", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const circles = document.querySelectorAll(".gradient-primary");
|
||||
expect(circles.length).toBe(3);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const section = document.querySelector('#how-it-works');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <HowItWorksSection class="custom-how" />);
|
||||
const section = document.querySelector("section.custom-how");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeaturesGridSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("Platform Features");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("Comprehensive protection");
|
||||
});
|
||||
|
||||
it("renders all 6 feature cards", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("DarkWatch");
|
||||
expect(document.body.textContent).toContain("VoicePrint");
|
||||
expect(document.body.textContent).toContain("SpamShield");
|
||||
expect(document.body.textContent).toContain("HomeTitle");
|
||||
expect(document.body.textContent).toContain("RemoveBrokers");
|
||||
expect(document.body.textContent).toContain("Family Plans");
|
||||
});
|
||||
|
||||
it("renders 6 Card components", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it("renders feature descriptions", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("dark web monitoring");
|
||||
expect(document.body.textContent).toContain("voice clone detection");
|
||||
expect(document.body.textContent).toContain("scam call blocking");
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const section = document.querySelector('#features');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <FeaturesGridSection class="custom-features" />);
|
||||
const section = document.querySelector("section.custom-features");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses responsive grid layout", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-2");
|
||||
expect(grid!.className).toContain("lg:grid-cols-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForUsersSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("For Everyone");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Whether you're protecting yourself",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders both panels", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("For Individuals");
|
||||
expect(document.body.textContent).toContain("For Families");
|
||||
});
|
||||
|
||||
it("renders individual panel description", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Personal identity protection",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders family panel description", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("Group management tools");
|
||||
});
|
||||
|
||||
it("renders bullet items for individuals", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Monitor personal email and phone numbers",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Dark web credential scanning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders bullet items for families", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Add unlimited family members",
|
||||
);
|
||||
expect(document.body.textContent).toContain("Shared alert dashboard");
|
||||
});
|
||||
|
||||
it("renders checkmark icons", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const checkmarks = document.querySelectorAll(
|
||||
'svg path[fill="var(--color-success)"]',
|
||||
);
|
||||
expect(checkmarks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders 2 Card components for panels", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const section = document.querySelector('#for-users');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <ForUsersSection class="custom-users" />);
|
||||
const section = document.querySelector("section.custom-users");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses two-column grid on desktop", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhyKordantSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain("Why Kordant");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Built on cutting-edge technology",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders all 3 value prop cards", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain("Proactive, Not Reactive");
|
||||
expect(document.body.textContent).toContain("AI-Powered Detection");
|
||||
expect(document.body.textContent).toContain("Privacy First");
|
||||
});
|
||||
|
||||
it("renders value prop descriptions", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"detect threats before they cause damage",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Machine learning models trained",
|
||||
);
|
||||
expect(document.body.textContent).toContain("encrypted and private");
|
||||
});
|
||||
|
||||
it("renders bullet items for each card", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Real-time dark web scanning",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Deepfake voice identification",
|
||||
);
|
||||
expect(document.body.textContent).toContain("End-to-end encrypted data");
|
||||
});
|
||||
|
||||
it("renders 3 Card components", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(3);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const section = document.querySelector('#why-kordant');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <WhyKordantSection class="custom-why" />);
|
||||
const section = document.querySelector("section.custom-why");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses three-column grid on desktop", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CTABannerSection", () => {
|
||||
it("renders the CTA headline", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Ready to protect your identity?",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the CTA subtext", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Join thousands of users",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders Create Account button", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain("Create Account");
|
||||
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||
expect(primaryBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Sign In button", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain("Sign In");
|
||||
});
|
||||
|
||||
it("has Create Account link to /signup", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const signupLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/signup",
|
||||
);
|
||||
expect(signupLink).toBeTruthy();
|
||||
expect(signupLink!.textContent).toContain("Create Account");
|
||||
});
|
||||
|
||||
it("has Sign In link to /login", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const loginLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/login",
|
||||
);
|
||||
expect(loginLink).toBeTruthy();
|
||||
expect(loginLink!.textContent).toContain("Sign In");
|
||||
});
|
||||
|
||||
it("renders 2 buttons", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const section = document.querySelector('#cta');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <CTABannerSection class="custom-cta" />);
|
||||
const section = document.querySelector("section.custom-cta");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses centered text layout", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const inner = document.querySelector(".text-center");
|
||||
expect(inner).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses gradient card for CTA banner", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const card = document.querySelector(".gradient-card");
|
||||
expect(card).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ function RealtimeIndicator() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUnread}
|
||||
class="relative flex items-center justify-center w-6 h-6 rounded-full bg-[var(--color-error)] text-white text-[10px] font-bold leading-none transition-transform hover:scale-110"
|
||||
class="relative flex items-center justify-center w-6 h-6 rounded-full bg-(--color-error) text-white text-[10px] font-bold leading-none transition-transform hover:scale-110"
|
||||
aria-label={`${unreadCount()} unread alerts`}
|
||||
>
|
||||
{unreadCount() > 99 ? "99+" : unreadCount()}
|
||||
@@ -152,17 +152,24 @@ function RealtimeIndicator() {
|
||||
<Show
|
||||
when={connectionStatus() === "connected"}
|
||||
fallback={
|
||||
connectionStatus() === "reconnecting" || connectionStatus() === "connecting" ? (
|
||||
<div class="flex items-center gap-1 text-[10px] text-[var(--color-warning)]" aria-label="Reconnecting">
|
||||
connectionStatus() === "reconnecting" ||
|
||||
connectionStatus() === "connecting" ? (
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] text-(--color-warning)"
|
||||
aria-label="Reconnecting"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-warning)] opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-warning)]" />
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-(--color-warning) opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-(--color-warning)" />
|
||||
</span>
|
||||
<span class="hidden sm:inline">Reconnecting</span>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" aria-label="Offline">
|
||||
<span class="inline-flex rounded-full h-2 w-2 bg-[var(--color-text-muted)]" />
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] text-(--color-text-muted)"
|
||||
aria-label="Offline"
|
||||
>
|
||||
<span class="inline-flex rounded-full h-2 w-2 bg-(--color-text-muted)" />
|
||||
<span class="hidden sm:inline">Offline</span>
|
||||
</div>
|
||||
)
|
||||
@@ -170,8 +177,8 @@ function RealtimeIndicator() {
|
||||
>
|
||||
<div class="flex items-center gap-1" aria-label="Connected">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-success)] opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-success)]" />
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-(--color-success) opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-(--color-success)" />
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -197,7 +204,11 @@ export default function Navbar() {
|
||||
return location.pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const NavLink = (props: { href: string; label: string; mobile?: boolean }) => (
|
||||
const NavLink = (props: {
|
||||
href: string;
|
||||
label: string;
|
||||
mobile?: boolean;
|
||||
}) => (
|
||||
<A
|
||||
href={props.href}
|
||||
class={cn(
|
||||
@@ -205,9 +216,11 @@ export default function Navbar() {
|
||||
? "block px-3 py-2 rounded-lg text-base font-medium transition-colors"
|
||||
: "text-sm font-medium transition-colors",
|
||||
isActive(props.href)
|
||||
? "text-[var(--color-text-primary)]"
|
||||
? "text-text-primary"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
props.mobile && !isActive(props.href) && "hover:bg-[var(--color-bg-secondary)]",
|
||||
props.mobile &&
|
||||
!isActive(props.href) &&
|
||||
"hover:bg-[var(--color-bg-secondary)]",
|
||||
)}
|
||||
onClick={() => props.mobile && setMobileOpen(false)}
|
||||
>
|
||||
@@ -234,10 +247,14 @@ export default function Navbar() {
|
||||
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
{marketingLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} />
|
||||
))}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
{productLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} />
|
||||
))}
|
||||
</SignedIn>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +280,7 @@ export default function Navbar() {
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
class="p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<Show
|
||||
when={mobileOpen()}
|
||||
@@ -304,12 +321,12 @@ export default function Navbar() {
|
||||
<div class="md:hidden glass border-t border-[var(--color-border)]">
|
||||
<div class="px-4 py-4 space-y-1">
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => (
|
||||
{marketingLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => (
|
||||
{productLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedIn>
|
||||
|
||||
@@ -4,29 +4,20 @@ export interface OnboardingData {
|
||||
familyInvites: string[];
|
||||
}
|
||||
|
||||
export function getCheckoutUrl(plan: string): string | null {
|
||||
const prices: Record<string, string | undefined> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
};
|
||||
const priceId = prices[plan];
|
||||
if (!priceId) return null;
|
||||
return `/billing/checkout?priceId=${priceId}`;
|
||||
}
|
||||
|
||||
export async function submitOnboarding(data: OnboardingData) {
|
||||
const { api } = await import("~/lib/api");
|
||||
|
||||
if (data.plan) {
|
||||
try {
|
||||
const prices: Record<string, string | undefined> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
};
|
||||
const priceId = prices[data.plan];
|
||||
if (priceId) {
|
||||
await api.billing.createCheckoutSession.mutate({
|
||||
priceId,
|
||||
successUrl: `${window.location.origin}/dashboard`,
|
||||
cancelUrl: `${window.location.origin}/onboarding`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// billing setup not required for free plan
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data.watchlistItems) {
|
||||
const type = item.includes("@") ? "EMAIL" : "PHONE";
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal, For, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Button, Input, Badge } from "~/components/ui";
|
||||
import { submitOnboarding } from "~/lib/auth";
|
||||
import { submitOnboarding, getCheckoutUrl } from "~/lib/auth";
|
||||
import type { OnboardingData } from "~/lib/auth";
|
||||
|
||||
const plans = [
|
||||
@@ -86,6 +86,12 @@ export default function OnboardingPage() {
|
||||
}
|
||||
|
||||
async function completeOnboarding() {
|
||||
const checkoutUrl = getCheckoutUrl(plan());
|
||||
if (checkoutUrl) {
|
||||
navigate(checkoutUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: OnboardingData = {
|
||||
@@ -392,23 +398,12 @@ export default function OnboardingPage() {
|
||||
selected items and will alert you of any threats.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="w-full"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
{!submitting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={completeOnboarding}
|
||||
class="w-full"
|
||||
>
|
||||
Submit onboarding data
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="w-full max-w-xs"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
34
web/src/routes/api/stripe/session-status.ts
Normal file
34
web/src/routes/api/stripe/session-status.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { stripe } from "~/server/stripe";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
const sessionId = url.searchParams.get("session_id");
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: "Missing session_id" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ["customer_details.email"],
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: session.status,
|
||||
customer_email: typeof session.customer_details === "string"
|
||||
? undefined
|
||||
: session.customer_details?.email,
|
||||
}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "Failed to retrieve session" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
89
web/src/routes/billing/checkout.tsx
Normal file
89
web/src/routes/billing/checkout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import EmbeddedCheckout from "~/components/EmbeddedCheckout";
|
||||
import { api } from "~/lib/api";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
const priceMap: Record<string, string> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC ?? "",
|
||||
plus: process.env.STRIPE_PRICE_PLUS ?? "",
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM ?? "",
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [clientSecret, setClientSecret] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
onMount(async () => {
|
||||
const plan = Array.isArray(searchParams.plan) ? searchParams.plan[0] : searchParams.plan;
|
||||
const priceIdParam = Array.isArray(searchParams.priceId) ? searchParams.priceId[0] : searchParams.priceId;
|
||||
const priceId = priceIdParam ?? (plan ? priceMap[plan] : "");
|
||||
|
||||
if (!priceId) {
|
||||
setError("No plan selected. Please select a plan to continue.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/billing/return`;
|
||||
const result = await api.billing.createCheckoutSession.mutate({
|
||||
priceId,
|
||||
returnUrl,
|
||||
});
|
||||
setClientSecret(result.clientSecret);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create checkout session");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="min-h-screen py-8 md:py-12">
|
||||
<Title>Checkout — Kordant</Title>
|
||||
<PageContainer>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)]">Complete your purchase</h1>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||
Secure payment powered by Stripe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center min-h-[300px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Preparing checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error() && !loading()}>
|
||||
<div class="text-center py-8 border border-[var(--color-border)] rounded-xl">
|
||||
<p class="text-[var(--color-error)] mb-4">{error()}</p>
|
||||
<button
|
||||
onClick={() => navigate("/pricing")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
← Back to pricing
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={clientSecret() && !loading() && !error()}>
|
||||
<EmbeddedCheckout
|
||||
clientSecret={clientSecret()}
|
||||
onCheckoutComplete={() => navigate("/dashboard")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
131
web/src/routes/billing/return.tsx
Normal file
131
web/src/routes/billing/return.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
export default function ReturnPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [status, setStatus] = createSignal<"loading" | "complete" | "open" | "error">("loading");
|
||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||
|
||||
onMount(async () => {
|
||||
const sessionId = Array.isArray(searchParams.session_id)
|
||||
? searchParams.session_id[0]
|
||||
: searchParams.session_id;
|
||||
|
||||
if (!sessionId) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stripe/session-status?session_id=${sessionId}`);
|
||||
if (!response.ok) throw new Error("Failed to check session status");
|
||||
const data = await response.json();
|
||||
setStatus(data.status);
|
||||
setCustomerEmail(data.customer_email ?? "");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
});
|
||||
|
||||
const isComplete = status() === "complete";
|
||||
const isOpen = status() === "open";
|
||||
const isError = status() === "error";
|
||||
|
||||
return (
|
||||
<main class="min-h-screen py-8 md:py-12">
|
||||
<Title>Payment Status — Kordant</Title>
|
||||
<PageContainer>
|
||||
<div class="max-w-xl mx-auto text-center">
|
||||
<Show when={status() === "loading"}>
|
||||
<div class="flex items-center justify-center min-h-[300px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Checking payment status...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isOpen}>
|
||||
<div class="py-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Payment in progress
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
Your payment session is still open. Please complete the checkout process.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/billing/checkout")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
← Return to checkout
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isComplete}>
|
||||
<div class="py-8">
|
||||
<div class="h-16 w-16 rounded-full gradient-primary flex items-center justify-center mx-auto mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17L4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Payment successful!
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-2">
|
||||
We appreciate your business!
|
||||
</p>
|
||||
<Show when={customerEmail()}>
|
||||
<p class="text-sm text-[var(--color-text-tertiary)] mb-6">
|
||||
A confirmation email will be sent to {customerEmail()}.
|
||||
</p>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="px-6 py-3 rounded-lg gradient-primary text-white font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isError}>
|
||||
<div class="py-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
We couldn't verify your payment status. Please try again.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => navigate("/billing/checkout")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
Try checkout again
|
||||
</button>
|
||||
<span class="text-[var(--color-text-tertiary)]">or</span>
|
||||
<button
|
||||
onClick={() => navigate("/pricing")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
View plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function BlogPage() {
|
||||
|
||||
// Fetch all published posts
|
||||
const allPosts = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res => {
|
||||
return api.blog.list.query({ limit: "100" }).then((res) => {
|
||||
setLoading(false);
|
||||
return res.posts;
|
||||
});
|
||||
@@ -32,9 +32,9 @@ export default function BlogPage() {
|
||||
|
||||
// Fetch featured post
|
||||
const featuredPost = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res =>
|
||||
res.posts.find((p: any) => p.featured) ?? null
|
||||
);
|
||||
return api.blog.list
|
||||
.query({ limit: "100" })
|
||||
.then((res) => res.posts.find((p: any) => p.featured) ?? null);
|
||||
});
|
||||
|
||||
// Filtered + visible posts
|
||||
@@ -51,8 +51,8 @@ export default function BlogPage() {
|
||||
return filtered.slice(0, visibleCount());
|
||||
});
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const posts = allPosts();
|
||||
const filtered = createMemo(async () => {
|
||||
const posts = await allPosts();
|
||||
if (!posts) return [];
|
||||
const tag = selectedTag();
|
||||
if (!tag) return posts;
|
||||
@@ -77,7 +77,8 @@ export default function BlogPage() {
|
||||
Kordant Blog
|
||||
</h1>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Insights on identity protection, AI safety, and the latest digital threats
|
||||
Insights on identity protection, AI safety, and the latest digital
|
||||
threats
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
@@ -92,19 +93,37 @@ export default function BlogPage() {
|
||||
<A href={`/blog/${fp().slug}`}>
|
||||
<Card class="flex flex-col md:flex-row gap-6 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="md:w-64 h-40 md:h-auto bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg flex-shrink-0 flex items-center justify-center">
|
||||
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" class="text-[var(--color-brand-primary)] opacity-40">
|
||||
<path d="M14 2L2 8v8c0 7 6 12 12 13 6-1 12-6 12-13V8L14 2z" stroke="currentColor" stroke-width="2"/>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
class="text-[var(--color-brand-primary)] opacity-40"
|
||||
>
|
||||
<path
|
||||
d="M14 2L2 8v8c0 7 6 12 12 13 6-1 12-6 12-13V8L14 2z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Badge variant="info" class="text-xs">Featured</Badge>
|
||||
<Badge variant="info" class="text-xs">
|
||||
Featured
|
||||
</Badge>
|
||||
<span class="text-xs text-[var(--color-text-tertiary)]">
|
||||
{fp().publishedAt ? new Date(fp().publishedAt).toLocaleDateString() : ""}
|
||||
{fp().publishedAt
|
||||
? new Date(fp().publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)] mb-2">{fp().title}</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2">{fp().excerpt}</p>
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
{fp().title}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2">
|
||||
{fp().excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
@@ -121,7 +140,10 @@ export default function BlogPage() {
|
||||
<div class="flex flex-wrap items-center gap-2 mb-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(null); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setVisibleCount(POSTS_PER_PAGE);
|
||||
}}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
!selectedTag()
|
||||
@@ -135,7 +157,10 @@ export default function BlogPage() {
|
||||
{({ tag, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(tag); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
onClick={() => {
|
||||
setSelectedTag(tag);
|
||||
setVisibleCount(POSTS_PER_PAGE);
|
||||
}}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
selectedTag() === tag
|
||||
@@ -157,9 +182,27 @@ export default function BlogPage() {
|
||||
<A href={`/blog/${post.slug}`}>
|
||||
<Card class="h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="h-40 bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg mb-4 flex items-center justify-center text-[var(--color-text-tertiary)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" class="opacity-40">
|
||||
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 12h24M12 6v20" stroke="currentColor" stroke-width="2"/>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
class="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="4"
|
||||
y="6"
|
||||
width="24"
|
||||
height="20"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M4 12h24M12 6v20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
@@ -176,7 +219,9 @@ export default function BlogPage() {
|
||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)] mt-auto">
|
||||
<span>{post.authorName || "Kordant"}</span>
|
||||
<span>
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : ""}
|
||||
{post.publishedAt
|
||||
? new Date(post.publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
{" · "}
|
||||
{readingTime(post.content)}
|
||||
</span>
|
||||
@@ -189,7 +234,9 @@ export default function BlogPage() {
|
||||
|
||||
<Show when={visible().length === 0}>
|
||||
<div class="text-center py-16">
|
||||
<p class="text-[var(--color-text-secondary)] text-lg">No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-lg">
|
||||
No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -228,73 +228,74 @@ export default function Home() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="overflow-hidden" style="--cut: clamp(16px, 2.5vw, 40px)">
|
||||
<div style="--cut: clamp(16px, 2.5vw, 40px)">
|
||||
<Title>Kordant — AI-Powered Identity Protection</Title>
|
||||
|
||||
{/* Hero */}
|
||||
{/* Fixed background — stays locked while content scrolls over it */}
|
||||
<ColorWaveBackground yOffset={-0.1} scale={0.65} speed={0.5} />
|
||||
<div class="relative z-10">
|
||||
<section>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 md:w-16 md:h-16">
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z" fill="white" fill-opacity="0.9" />
|
||||
<path d="M20 24l3 2.5 5-5" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Get Started</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">Learn More</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
{/* Hero */}
|
||||
<section class="relative z-10">
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 md:w-16 md:h-16">
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z" fill="white" fill-opacity="0.9" />
|
||||
<path d="M20 24l3 2.5 5-5" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Get Started</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">Learn More</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<div
|
||||
class="bg-dot-grid relative z-10"
|
||||
<section
|
||||
id="how-it-works"
|
||||
class="relative z-10 bg-dot-grid scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="how-it-works" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
How It Works
|
||||
@@ -333,17 +334,18 @@ export default function Home() {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* Platform Features */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 py-8 -my-10"
|
||||
<section
|
||||
id="features"
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 py-8 -my-10 scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 0, 100% 0, 100% calc(100% - var(--cut)), 0 100%)" }}
|
||||
>
|
||||
<section id="features" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Platform Features
|
||||
@@ -373,17 +375,18 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* For Everyone */}
|
||||
<div
|
||||
class="bg-dot-grid"
|
||||
<section
|
||||
id="for-users"
|
||||
class="relative z-10 bg-dot-grid scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="for-users" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
For Everyone
|
||||
@@ -421,17 +424,18 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* Why Kordant + CTA */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 pt-8 -mt-10"
|
||||
<section
|
||||
id="why-kordant"
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 pt-8 -mt-10 scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="why-kordant" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Why Kordant
|
||||
@@ -469,31 +473,33 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
<section id="cta" class="py-20 md:py-28 scroll-mt-16">
|
||||
<section id="cta" class="scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Create Account</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">Sign In</Button>
|
||||
</A>
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Create Account</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">Sign In</Button>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ const plans: Plan[] = [
|
||||
price: "$9",
|
||||
period: "/month",
|
||||
description: "Essential identity protection for individuals",
|
||||
features: ["Dark web monitoring", "Email breach alerts", "Basic scam call blocking", "Monthly reports"],
|
||||
features: [
|
||||
"Dark web monitoring",
|
||||
"Email breach alerts",
|
||||
"Basic scam call blocking",
|
||||
"Monthly reports",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
@@ -35,7 +40,13 @@ const plans: Plan[] = [
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "Advanced protection for you and your family",
|
||||
features: ["Everything in Basic", "VoicePrint AI detection", "HomeTitle fraud alerts", "RemoveBrokers automation", "Family sharing (up to 5)"],
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"VoicePrint AI detection",
|
||||
"HomeTitle fraud alerts",
|
||||
"RemoveBrokers automation",
|
||||
"Family sharing (up to 5)",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: true,
|
||||
},
|
||||
@@ -44,7 +55,14 @@ const plans: Plan[] = [
|
||||
price: "$39",
|
||||
period: "/month",
|
||||
description: "Maximum security for the whole household",
|
||||
features: ["Everything in Plus", "Unlimited family members", "Priority support 24/7", "Real-time alert correlation", "Advanced analytics dashboard", "Data broker suppression"],
|
||||
features: [
|
||||
"Everything in Plus",
|
||||
"Unlimited family members",
|
||||
"Priority support 24/7",
|
||||
"Real-time alert correlation",
|
||||
"Advanced analytics dashboard",
|
||||
"Data broker suppression",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
@@ -80,30 +98,90 @@ const faqs: FAQ[] = [
|
||||
const comparisonFeatures = [
|
||||
{ feature: "Dark web monitoring", basic: true, plus: true, premium: true },
|
||||
{ feature: "Email breach alerts", basic: true, plus: true, premium: true },
|
||||
{ feature: "Basic scam call blocking", basic: true, plus: true, premium: true },
|
||||
{
|
||||
feature: "Basic scam call blocking",
|
||||
basic: true,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{ feature: "Monthly reports", basic: true, plus: true, premium: true },
|
||||
{ feature: "VoicePrint AI detection", basic: false, plus: true, premium: true },
|
||||
{ feature: "HomeTitle fraud alerts", basic: false, plus: true, premium: true },
|
||||
{ feature: "RemoveBrokers automation", basic: false, plus: true, premium: true },
|
||||
{ feature: "Family sharing", basic: false, plus: "Up to 5", premium: "Unlimited" },
|
||||
{ feature: "Priority support 24/7", basic: false, plus: false, premium: true },
|
||||
{ feature: "Real-time alert correlation", basic: false, plus: false, premium: true },
|
||||
{
|
||||
feature: "VoicePrint AI detection",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "HomeTitle fraud alerts",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "RemoveBrokers automation",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "Family sharing",
|
||||
basic: false,
|
||||
plus: "Up to 5",
|
||||
premium: "Unlimited",
|
||||
},
|
||||
{
|
||||
feature: "Priority support 24/7",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "Real-time alert correlation",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
{ feature: "Advanced analytics", basic: false, plus: false, premium: true },
|
||||
{ feature: "Data broker suppression", basic: false, plus: false, premium: true },
|
||||
{
|
||||
feature: "Data broker suppression",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
];
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="var(--color-text-muted)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -112,33 +190,52 @@ export default function PricingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [openFaq, setOpenFaq] = createSignal<string | null>(null);
|
||||
|
||||
const signupUrl = () => `/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`;
|
||||
const signupUrl = () =>
|
||||
`/signup${
|
||||
searchParams.utm_source
|
||||
? `?utm_source=${searchParams.utm_source}&utm_medium=${
|
||||
searchParams.utm_medium || ""
|
||||
}&utm_campaign=${searchParams.utm_campaign || ""}`
|
||||
: ""
|
||||
}`;
|
||||
|
||||
const planToId: Record<string, string> = {
|
||||
Basic: "basic",
|
||||
Plus: "plus",
|
||||
Premium: "premium",
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>Kordant Pricing — AI-Powered Identity Protection Plans</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<div class="absolute inset-0 bg-linear-to-b from-(--color-brand-primary)/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<Badge variant="info" class="mb-4">Simple Pricing</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||
<Badge variant="info" class="mb-4">
|
||||
Simple Pricing
|
||||
</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-text-primary mb-6">
|
||||
Protection That Fits{" "}
|
||||
<span class="text-gradient-primary">Your Budget</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel anytime.
|
||||
<p class="text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel
|
||||
anytime.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />14-day free trial
|
||||
<CheckIcon />
|
||||
14-day free trial
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />No credit card required
|
||||
<CheckIcon />
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />Cancel anytime
|
||||
<CheckIcon />
|
||||
Cancel anytime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +250,8 @@ export default function PricingPage() {
|
||||
<Card
|
||||
class={cn(
|
||||
"relative flex flex-col",
|
||||
plan.popular && "ring-2 ring-[var(--color-brand-primary)] shadow-glow-primary",
|
||||
plan.popular &&
|
||||
"ring-2 ring-(--color-brand-primary) shadow-glow-primary",
|
||||
)}
|
||||
>
|
||||
<Show when={plan.popular}>
|
||||
@@ -162,25 +260,36 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{plan.name}</h3>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">{plan.description}</p>
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-1">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p class="text-sm text-text-secondary mb-4">
|
||||
{plan.description}
|
||||
</p>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<span class="text-4xl font-bold text-[var(--color-text-primary)]">{plan.price}</span>
|
||||
<span class="text-sm text-[var(--color-text-tertiary)]">{plan.period}</span>
|
||||
<span class="text-4xl font-bold text-text-primary">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span class="text-sm text-text-tertiary">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 flex-1">
|
||||
<For each={plan.features}>
|
||||
{(feature) => (
|
||||
<li class="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<li class="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<CheckIcon />
|
||||
{feature}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant={plan.popular ? "primary" : "secondary"} class="w-full">
|
||||
<A href={`/billing/checkout?plan=${planToId[plan.name]}`}>
|
||||
<Button
|
||||
variant={plan.popular ? "primary" : "secondary"}
|
||||
class="w-full"
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</A>
|
||||
@@ -202,25 +311,59 @@ export default function PricingPage() {
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-[var(--color-border)]">
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Feature</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Basic</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-[var(--color-brand-primary)]">Plus</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Premium</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Feature
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Basic
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-[var(--color-brand-primary)]">
|
||||
Plus
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Premium
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={comparisonFeatures}>
|
||||
{(row) => (
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<td class="px-4 py-3 text-sm text-[var(--color-text-primary)]">{row.feature}</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.basic === true ? <CheckIcon /> : row.basic === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.basic}</span>}
|
||||
<td class="px-4 py-3 text-sm text-[var(--color-text-primary)]">
|
||||
{row.feature}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.plus === true ? <CheckIcon /> : row.plus === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.plus}</span>}
|
||||
{row.basic === true ? (
|
||||
<CheckIcon />
|
||||
) : row.basic === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.basic}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.premium === true ? <CheckIcon /> : row.premium === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.premium}</span>}
|
||||
{row.plus === true ? (
|
||||
<CheckIcon />
|
||||
) : row.plus === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.plus}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.premium === true ? (
|
||||
<CheckIcon />
|
||||
) : row.premium === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.premium}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -244,7 +387,7 @@ export default function PricingPage() {
|
||||
{(faq) => {
|
||||
const isOpen = () => openFaq() === faq.q;
|
||||
return (
|
||||
<div class="border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
<div class="border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
@@ -256,9 +399,18 @@ export default function PricingPage() {
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class={cn("transition-transform duration-200", isOpen() && "rotate-180")}
|
||||
class={cn(
|
||||
"transition-transform duration-200",
|
||||
isOpen() && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M4 6l4 4 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isOpen()}>
|
||||
@@ -275,17 +427,22 @@ export default function PricingPage() {
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-brand-primary)]">
|
||||
<section class="py-16 bg-(--color-brand-primary)">
|
||||
<PageContainer>
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-white/80 mb-8 max-w-2xl mx-auto">
|
||||
Join 50,000+ users who trust Kordant for AI-powered identity protection.
|
||||
Join 50,000+ users who trust Kordant for AI-powered identity
|
||||
protection.
|
||||
</p>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant="primary" size="lg" class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg"
|
||||
>
|
||||
Get Started Free
|
||||
</Button>
|
||||
</A>
|
||||
|
||||
@@ -84,8 +84,8 @@ function createCaller(user: User | null) {
|
||||
createCheckoutSession: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreateCheckoutSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const i = input as { priceId: string; successUrl: string; cancelUrl: string };
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.successUrl, i.cancelUrl);
|
||||
const i = input as { priceId: string; returnUrl: string };
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl);
|
||||
}),
|
||||
createPortalSession: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreatePortalSessionSchema))
|
||||
@@ -159,20 +159,20 @@ describe("billing.getSubscription", () => {
|
||||
});
|
||||
|
||||
describe("billing.createCheckoutSession", () => {
|
||||
it("creates checkout session and returns URL", async () => {
|
||||
it("creates checkout session and returns clientSecret", async () => {
|
||||
mockCreateCheckoutSession.mockResolvedValue({
|
||||
url: "https://checkout.stripe.com/session_123",
|
||||
clientSecret: "cs_123_secret",
|
||||
sessionId: "session_123",
|
||||
});
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.createCheckoutSession({
|
||||
priceId: "price_basic",
|
||||
successUrl: "https://example.com/success",
|
||||
cancelUrl: "https://example.com/cancel",
|
||||
returnUrl: "https://example.com/return",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://checkout.stripe.com/session_123");
|
||||
expect(result.clientSecret).toBe("cs_123_secret");
|
||||
expect(result.sessionId).toBe("session_123");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ export const billingRouter = createTRPCRouter({
|
||||
ctx.user.id,
|
||||
ctx.user.email,
|
||||
input.priceId,
|
||||
input.successUrl,
|
||||
input.cancelUrl,
|
||||
input.returnUrl,
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { object, string, url, minLength, optional, picklist } from "valibot";
|
||||
|
||||
export const CreateCheckoutSessionSchema = object({
|
||||
priceId: string([minLength(1)]),
|
||||
successUrl: string([url()]),
|
||||
cancelUrl: string([url()]),
|
||||
returnUrl: string([url()]),
|
||||
});
|
||||
|
||||
export const CreatePortalSessionSchema = object({
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("getOrCreateCustomer", () => {
|
||||
});
|
||||
|
||||
describe("createCheckoutSession", () => {
|
||||
it("creates a Stripe checkout session and returns URL", async () => {
|
||||
it("creates an embedded Stripe checkout session and returns clientSecret", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -112,20 +112,25 @@ describe("createCheckoutSession", () => {
|
||||
});
|
||||
|
||||
(stripe.checkout.sessions.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
url: "https://checkout.stripe.com/session_123",
|
||||
id: "session_123",
|
||||
client_secret: "cs_123_secret",
|
||||
});
|
||||
|
||||
const result = await createCheckoutSession(
|
||||
"u1",
|
||||
"a@b.com",
|
||||
"price_basic",
|
||||
"https://example.com/success",
|
||||
"https://example.com/cancel",
|
||||
"https://example.com/return",
|
||||
);
|
||||
|
||||
expect(result.url).toBe("https://checkout.stripe.com/session_123");
|
||||
expect(result.clientSecret).toBe("cs_123_secret");
|
||||
expect(result.sessionId).toBe("session_123");
|
||||
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ui_mode: "embedded_page",
|
||||
return_url: "https://example.com/return?session_id={CHECKOUT_SESSION_ID}",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,21 +40,20 @@ export async function createCheckoutSession(
|
||||
userId: string,
|
||||
email: string,
|
||||
priceId: string,
|
||||
successUrl: string,
|
||||
cancelUrl: string,
|
||||
returnUrl: string,
|
||||
) {
|
||||
const customerId = await getOrCreateCustomer(userId, email);
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
ui_mode: "embedded_page",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
return { url: session.url, sessionId: session.id };
|
||||
return { clientSecret: session.client_secret ?? "", sessionId: session.id };
|
||||
}
|
||||
|
||||
export async function createPortalSession(customerId: string, returnUrl: string) {
|
||||
|
||||
Reference in New Issue
Block a user