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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user