feat(web): pricing — hero CTA, monthly/yearly toggle, numeric savings, popular elevation

- Hero gets a primary "start trial" CTA + secondary "see plans" scroll-to anchor
- Adds monthly/yearly billing toggle that switches displayed amounts and surfaces
  effective per-month yearly cost
- Yearly savings shown as both percent and TL/year (no more vague "save")
- Full Paket card visually elevated (ring + tinted bg + slight vertical offset on lg)
- Plan CTA carries plan=key into /register; chosen period stashed in localStorage
- New PostHog events: pricing_hero_cta_clicked, pricing_period_toggled,
  pricing_plan_cta_clicked
This commit is contained in:
2026-05-31 22:58:18 +03:00
parent 984a02e9a3
commit eb8d8c2dee
3 changed files with 209 additions and 45 deletions

View File

@@ -522,7 +522,18 @@
"popular": "Popular",
"orYearly": "or {price}/year",
"yearlySave": "· 2 months free",
"yearlySavePercent": "· save {percent}%",
"yearlySaveAmount": "save {percent}% — {amount}/year",
"yearlyBilled": "billed {price} per year",
"getStarted": "Start Free",
"heroCta": "Start the 30-day trial",
"heroSecondary": "See the plans",
"billingPeriodLabel": "Billing period",
"billing": {
"monthly": "Monthly",
"yearly": "Yearly"
},
"yearlyBadge": "2 months free",
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans",
"how": {
"title": "How it works",

View File

@@ -522,7 +522,18 @@
"popular": "Popüler",
"orYearly": "veya {price}/yıl",
"yearlySave": "· 2 ay bedava",
"yearlySavePercent": "· %{percent} tasarruf",
"yearlySaveAmount": "%{percent} tasarruf — yılda {amount} kazanç",
"yearlyBilled": "yılda {price} tek seferde",
"getStarted": "Ücretsiz Başla",
"heroCta": "30 günlük denemeyi başlat",
"heroSecondary": "Planları gör",
"billingPeriodLabel": "Fatura dönemi",
"billing": {
"monthly": "Aylık",
"yearly": "Yıllık"
},
"yearlyBadge": "2 ay bedava",
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
"how": {
"title": "Nasıl çalışır",

View File

@@ -2,12 +2,14 @@ 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 { Check, ChevronDown, Info } from "lucide-react";
import { ArrowDown, Check, ChevronDown, Info } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/pricing")({
component: PricingPage,
@@ -52,8 +54,16 @@ export const plans = [
},
];
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"),
@@ -61,6 +71,12 @@ function PricingPage() {
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 />
@@ -85,6 +101,29 @@ function PricingPage() {
</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>
<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">
@@ -92,51 +131,14 @@ function PricingPage() {
<span>{t("subscription.planHelper")}</span>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* 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) => (
<Card
key={plan.key}
className={plan.popular ? "border-primary shadow-lg relative" : ""}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2">
{t("subscription.popular")}
</Badge>
)}
<CardHeader>
<CardTitle>{t(`subscription.plans.${plan.key}.name`)}</CardTitle>
<CardDescription>{t(`subscription.plans.${plan.key}.description`)}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-3xl font-bold">{formatTRY(plan.priceMonthly)}</span>
<span className="text-muted-foreground">{t("common.perMonth")}</span>
</div>
<p className="text-sm text-muted-foreground">
{t("pricing.orYearly", { price: formatTRY(plan.priceYearly) })}{" "}
<span className="font-medium text-primary">{t("pricing.yearlySave")}</span>
</p>
<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>
<Link to="/register">
<Button className="w-full" variant={plan.popular ? "default" : "outline"}>
{t("pricing.getStarted")}
</Button>
</Link>
</CardContent>
</Card>
<PlanCard key={plan.key} plan={plan} period={period} />
))}
</div>
@@ -152,6 +154,146 @@ function PricingPage() {
);
}
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;