Files
sase.tr/apps/web/src/routes/pricing.tsx
Semih Yesilyurt 931c0cfd85 feat(web): pricing — by-the-numbers stat strip, verifiable facts only
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.
2026-05-31 23:18:14 +03:00

438 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}