Adds a four-stat band below the hero CTAs — 27 brand catalogs, 1M+ OEM/alt parts, 30-day free trial, 7-day no-questions refund. Big tabular-nums numerals with small labels; divides on sm+. Every number is product or policy fact we can defend — no fabricated customer counts, no testimonial copy. Side effect: FAQPage JSON-LD now injected into <head> via useEffect (was an inline <script dangerouslySetInnerHTML>). Same SEO payload, less attack surface, tagged with data-sase-faq so it's diff-friendly.
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import { CredibilityStrip } from "@/components/credibility-strip";
|
||
import { SiteHeader } from "@/components/site-header";
|
||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||
import { useTranslation } from "@/lib/i18n";
|
||
import { capture } from "@/lib/posthog";
|
||
import { FULL_PLAN_BRAND_LIMIT, formatTRY } from "@sase/shared";
|
||
import { Button } from "@sase/ui";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||
import { Badge } from "@sase/ui";
|
||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||
import {
|
||
ArrowDown,
|
||
CalendarClock,
|
||
Check,
|
||
ChevronDown,
|
||
CreditCard,
|
||
Info,
|
||
RotateCcw,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
|
||
export const Route = createFileRoute("/pricing")({
|
||
component: PricingPage,
|
||
});
|
||
|
||
export const plans = [
|
||
{
|
||
key: "brand1",
|
||
brandLimit: 1,
|
||
priceMonthly: 20000,
|
||
priceYearly: 200000,
|
||
features: ["brandSelection", "vinSearch", "partsCatalog", "schemaViewer"],
|
||
},
|
||
{
|
||
key: "brand2",
|
||
brandLimit: 2,
|
||
priceMonthly: 35000,
|
||
priceYearly: 350000,
|
||
features: ["brandSelection", "vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
|
||
},
|
||
{
|
||
key: "brand3",
|
||
brandLimit: 3,
|
||
priceMonthly: 50000,
|
||
priceYearly: 500000,
|
||
features: ["brandSelection", "vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
|
||
},
|
||
{
|
||
key: "full",
|
||
brandLimit: FULL_PLAN_BRAND_LIMIT,
|
||
priceMonthly: 99900,
|
||
priceYearly: 999000,
|
||
popular: true,
|
||
features: [
|
||
"allBrands",
|
||
"vinSearch",
|
||
"partsCatalog",
|
||
"schemaViewer",
|
||
"prioritySupport",
|
||
"oemSearch",
|
||
],
|
||
},
|
||
];
|
||
|
||
type BillingPeriod = "monthly" | "yearly";
|
||
|
||
function scrollToPlans() {
|
||
const el = document.getElementById("plans");
|
||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
|
||
function PricingPage() {
|
||
const { t } = useTranslation();
|
||
const [period, setPeriod] = useState<BillingPeriod>("monthly");
|
||
|
||
usePageMeta({
|
||
title: t("pricing.pageTitle"),
|
||
description: t("pricing.metaDescription"),
|
||
canonical: "https://sase.tr/pricing",
|
||
});
|
||
|
||
function changePeriod(next: BillingPeriod) {
|
||
if (next === period) return;
|
||
setPeriod(next);
|
||
capture("pricing_period_toggled", { period: next });
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen">
|
||
<SiteHeader />
|
||
|
||
<main id="main-content" className="container mx-auto px-4 py-24">
|
||
<div className="mx-auto max-w-2xl text-center">
|
||
<p className="text-sm font-semibold uppercase tracking-wider text-primary">
|
||
{t("pricing.title")}
|
||
</p>
|
||
<h1 className="mt-3 text-4xl font-bold leading-tight">{t("pricing.heroTitle")}</h1>
|
||
<p className="mt-4 text-lg text-muted-foreground">{t("pricing.heroSubtitle")}</p>
|
||
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
|
||
{[
|
||
{ key: "pricing.trialFree", icon: CalendarClock },
|
||
{ key: "subscription.trustNoCard", icon: CreditCard },
|
||
{ key: "subscription.trustCancelAnytime", icon: RotateCcw },
|
||
{ key: "subscription.trustRefund", icon: Check },
|
||
].map(({ key, icon: Icon }) => (
|
||
<span key={key} className="flex items-center gap-1.5">
|
||
<Icon className="h-4 w-4 text-primary" />
|
||
{t(key)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Hero CTAs — primary trial start + jump-to-plans secondary */}
|
||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||
<Link to="/register">
|
||
<Button
|
||
size="lg"
|
||
onClick={() => capture("pricing_hero_cta_clicked", { target: "register" })}
|
||
>
|
||
{t("pricing.heroCta")}
|
||
</Button>
|
||
</Link>
|
||
<Button
|
||
variant="ghost"
|
||
size="lg"
|
||
onClick={() => {
|
||
capture("pricing_hero_cta_clicked", { target: "plans" });
|
||
scrollToPlans();
|
||
}}
|
||
>
|
||
{t("pricing.heroSecondary")}
|
||
<ArrowDown className="ml-1.5 h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<PricingStatsStrip />
|
||
|
||
<div className="mx-auto mt-10 flex max-w-2xl items-start justify-center gap-2 rounded-lg border border-border bg-muted/30 px-4 py-2.5 text-sm text-muted-foreground">
|
||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||
<span>{t("subscription.planHelper")}</span>
|
||
</div>
|
||
|
||
{/* Billing period toggle */}
|
||
<div className="mt-8 flex justify-center" id="plans">
|
||
<BillingPeriodToggle value={period} onChange={changePeriod} />
|
||
</div>
|
||
|
||
<div className="mt-8 grid items-stretch gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||
{plans.map((plan) => (
|
||
<PlanCard key={plan.key} plan={plan} period={period} />
|
||
))}
|
||
</div>
|
||
|
||
<div className="mx-auto mt-12 max-w-5xl">
|
||
<CredibilityStrip />
|
||
</div>
|
||
|
||
<PricingHowItWorks />
|
||
|
||
<PricingFaq />
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Stat row — verifiable product/policy facts only (catalog size, trial length,
|
||
// refund window). No customer-count claims, no testimonials.
|
||
function PricingStatsStrip() {
|
||
const { t } = useTranslation();
|
||
const items = [
|
||
{ value: "pricing.stats.brands.value", label: "pricing.stats.brands.label" },
|
||
{ value: "pricing.stats.parts.value", label: "pricing.stats.parts.label" },
|
||
{ value: "pricing.stats.trial.value", label: "pricing.stats.trial.label" },
|
||
{ value: "pricing.stats.refund.value", label: "pricing.stats.refund.label" },
|
||
];
|
||
return (
|
||
<div className="mx-auto mt-12 max-w-5xl">
|
||
<dl className="grid grid-cols-2 gap-y-6 rounded-2xl border border-border bg-muted/20 p-6 sm:grid-cols-4 sm:gap-y-0 sm:divide-x sm:divide-border">
|
||
{items.map((it) => (
|
||
<div
|
||
key={it.value}
|
||
className="flex flex-col items-center justify-center px-4 text-center"
|
||
>
|
||
<dt className="text-3xl font-bold leading-none tabular-nums text-foreground sm:text-4xl">
|
||
{t(it.value)}
|
||
</dt>
|
||
<dd className="mt-2 text-xs uppercase tracking-wider text-muted-foreground sm:text-sm sm:normal-case sm:tracking-normal">
|
||
{t(it.label)}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BillingPeriodToggle({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: BillingPeriod;
|
||
onChange: (next: BillingPeriod) => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<fieldset
|
||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted p-1"
|
||
aria-label={t("pricing.billingPeriodLabel")}
|
||
>
|
||
<legend className="sr-only">{t("pricing.billingPeriodLabel")}</legend>
|
||
{(["monthly", "yearly"] as const).map((opt) => (
|
||
<button
|
||
key={opt}
|
||
type="button"
|
||
aria-pressed={value === opt}
|
||
onClick={() => onChange(opt)}
|
||
className={`relative rounded-full px-5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
|
||
value === opt
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
{t(`pricing.billing.${opt}`)}
|
||
{opt === "yearly" && (
|
||
<span className="ml-2 inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||
{t("pricing.yearlyBadge")}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
function PlanCard({
|
||
plan,
|
||
period,
|
||
}: {
|
||
plan: (typeof plans)[number];
|
||
period: BillingPeriod;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const isYearly = period === "yearly";
|
||
|
||
// Yearly: show effective monthly cost (yearly / 12), plus the savings vs
|
||
// paying 12 × monthly. Saves the user mental math and surfaces the deal.
|
||
const headlineAmount = isYearly ? Math.round(plan.priceYearly / 12) : plan.priceMonthly;
|
||
const fullYearMonthly = plan.priceMonthly * 12;
|
||
const yearlySavings = fullYearMonthly - plan.priceYearly;
|
||
const yearlySavingsPercent = Math.round((yearlySavings / fullYearMonthly) * 100);
|
||
|
||
return (
|
||
<Card
|
||
className={
|
||
plan.popular
|
||
? "relative flex flex-col border-primary bg-gradient-to-b from-primary/[0.04] to-background shadow-lg ring-1 ring-primary/30 lg:-mt-4 lg:mb-4 overflow-visible"
|
||
: "relative flex flex-col overflow-visible"
|
||
}
|
||
>
|
||
{plan.popular && (
|
||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 shadow-sm">
|
||
{t("subscription.popular")}
|
||
</Badge>
|
||
)}
|
||
<CardHeader>
|
||
<CardTitle>{t(`subscription.plans.${plan.key}.name`)}</CardTitle>
|
||
<CardDescription>{t(`subscription.plans.${plan.key}.description`)}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-1 flex-col gap-4">
|
||
<div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className="text-3xl font-bold tabular-nums">{formatTRY(headlineAmount)}</span>
|
||
<span className="text-muted-foreground">{t("common.perMonth")}</span>
|
||
</div>
|
||
{isYearly ? (
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{t("pricing.yearlyBilled", { price: formatTRY(plan.priceYearly) })}{" "}
|
||
<span className="font-medium text-primary">
|
||
{t("pricing.yearlySaveAmount", {
|
||
percent: yearlySavingsPercent,
|
||
amount: formatTRY(yearlySavings),
|
||
})}
|
||
</span>
|
||
</p>
|
||
) : (
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{t("pricing.orYearly", { price: formatTRY(plan.priceYearly) })}{" "}
|
||
<span className="font-medium text-primary">
|
||
{t("pricing.yearlySavePercent", { percent: yearlySavingsPercent })}
|
||
</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
<ul className="space-y-2 text-sm">
|
||
{plan.features.map((f) => {
|
||
const featureLabel =
|
||
f === "brandSelection"
|
||
? t(`subscription.features.${f}`, { count: plan.brandLimit })
|
||
: t(`subscription.features.${f}`);
|
||
return (
|
||
<li key={f} className="flex items-center gap-2">
|
||
<Check className="h-4 w-4 text-primary" />
|
||
{featureLabel}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<div className="mt-auto pt-2">
|
||
<Link
|
||
to="/register"
|
||
search={{ plan: plan.key }}
|
||
onClick={() => {
|
||
if (typeof window !== "undefined") {
|
||
try {
|
||
localStorage.setItem("sase-pending-period", period);
|
||
} catch {
|
||
// localStorage blocked (private mode); we degrade silently.
|
||
}
|
||
}
|
||
capture("pricing_plan_cta_clicked", {
|
||
plan: plan.key,
|
||
period,
|
||
popular: !!plan.popular,
|
||
});
|
||
}}
|
||
>
|
||
<Button className="w-full" variant={plan.popular ? "default" : "outline"}>
|
||
{t("pricing.getStarted")}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function PricingHowItWorks() {
|
||
const { t } = useTranslation();
|
||
const steps = [1, 2, 3] as const;
|
||
return (
|
||
<section className="mx-auto mt-20 max-w-4xl">
|
||
<h2 className="text-center text-2xl font-bold">{t("pricing.how.title")}</h2>
|
||
{/* Vertical timeline rail (md+) ditches the "3 equal cards" cliché.
|
||
Each step alternates sides; the rail connects them. */}
|
||
<ol className="relative mt-10 space-y-10 md:space-y-12">
|
||
<div
|
||
aria-hidden="true"
|
||
className="absolute left-5 top-2 bottom-2 hidden w-px bg-gradient-to-b from-primary/40 via-primary/20 to-transparent md:left-1/2 md:block md:-translate-x-1/2"
|
||
/>
|
||
{steps.map((i) => {
|
||
const isLeft = i % 2 === 1;
|
||
return (
|
||
<li
|
||
key={i}
|
||
className={`relative md:grid md:grid-cols-2 md:items-center md:gap-10 ${
|
||
isLeft ? "" : "md:[&>*:first-child]:order-2"
|
||
}`}
|
||
>
|
||
<div className={`md:px-2 ${isLeft ? "md:text-right" : "md:text-left"}`}>
|
||
<div className="mb-3 flex items-center gap-3 md:hidden">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||
{i}
|
||
</div>
|
||
<h3 className="font-semibold leading-6">{t(`pricing.how.s${i}t`)}</h3>
|
||
</div>
|
||
<h3 className="hidden text-lg font-semibold leading-7 md:block">
|
||
{t(`pricing.how.s${i}t`)}
|
||
</h3>
|
||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||
{t(`pricing.how.s${i}d`)}
|
||
</p>
|
||
</div>
|
||
<div className="hidden md:flex md:items-center md:justify-center">
|
||
<div
|
||
aria-hidden="true"
|
||
className="flex h-12 w-12 items-center justify-center rounded-full border border-primary/30 bg-background text-base font-bold text-primary shadow-sm"
|
||
>
|
||
{i}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const FAQ_IDS = [1, 2, 3, 4, 5, 6] as const;
|
||
|
||
export function PricingFaq() {
|
||
const { t } = useTranslation();
|
||
|
||
// Inject FAQPage JSON-LD into <head> so Google can render rich-result Q/A
|
||
// blocks in SERP. Imperative head injection keeps us off React's `script`
|
||
// path and avoids dangerouslySetInnerHTML — content is i18n-sourced anyway,
|
||
// but this is also the cleanest way to render structured data.
|
||
useEffect(() => {
|
||
const payload = {
|
||
"@context": "https://schema.org",
|
||
"@type": "FAQPage",
|
||
mainEntity: FAQ_IDS.map((i) => ({
|
||
"@type": "Question",
|
||
name: t(`pricing.faq.q${i}`),
|
||
acceptedAnswer: { "@type": "Answer", text: t(`pricing.faq.a${i}`) },
|
||
})),
|
||
};
|
||
const script = document.createElement("script");
|
||
script.type = "application/ld+json";
|
||
script.setAttribute("data-sase-faq", "1");
|
||
script.text = JSON.stringify(payload);
|
||
document.head.appendChild(script);
|
||
return () => {
|
||
script.remove();
|
||
};
|
||
}, [t]);
|
||
|
||
return (
|
||
<section className="mx-auto mt-20 max-w-3xl">
|
||
<h2 className="text-center text-2xl font-bold">{t("pricing.faq.title")}</h2>
|
||
<div className="mt-8 divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||
{FAQ_IDS.map((i) => (
|
||
<details key={i} className="group">
|
||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-5 font-medium">
|
||
{t(`pricing.faq.q${i}`)}
|
||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||
</summary>
|
||
<p className="px-5 pb-5 text-sm leading-6 text-muted-foreground">
|
||
{t(`pricing.faq.a${i}`)}
|
||
</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|