dev #50

Merged
root merged 5 commits from dev into main 2026-05-25 19:23:08 +03:00
9 changed files with 269 additions and 45 deletions

View File

@@ -0,0 +1,32 @@
import { useTranslation } from "@/lib/i18n";
import { Boxes, Hash, Layers, ShieldCheck, Zap } from "lucide-react";
/**
* Compact trust bar of real, verifiable product facts (data scale, speed,
* security) — no fabricated customer counts or testimonials. Shared between
* the public pricing page and the in-app subscription page so both surfaces
* carry the same credibility signal. Copy lives under the `subscription.credibility.*`
* i18n keys.
*/
export function CredibilityStrip() {
const { t } = useTranslation();
const items = [
{ icon: Layers, key: "brands" },
{ icon: Boxes, key: "parts" },
{ icon: Hash, key: "oem" },
{ icon: Zap, key: "speed" },
{ icon: ShieldCheck, key: "security" },
] as const;
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-2xl border border-border bg-muted/20 px-5 py-4 sm:grid-cols-3 lg:grid-cols-5">
{items.map(({ icon: Icon, key }) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Icon className="h-4 w-4 shrink-0 text-primary" />
<span className="font-medium leading-5 text-foreground/85">
{t(`subscription.credibility.${key}`)}
</span>
</div>
))}
</div>
);
}

View File

@@ -433,12 +433,40 @@
},
"pricing": {
"title": "Pricing",
"heroTitle": "Find the right part in seconds",
"heroSubtitle": "Pick a plan and try it free for 30 days. No more wrong-part orders.",
"trialFree": "30 days free",
"subtitle": "Choose the plan that fits your needs.",
"metaDescription": "Chassis number and OEM parts search plans starting from 200 TL/month. Try free for 30 days.",
"popular": "Popular",
"orYearly": "or {price} TL/year",
"getStarted": "Get Started",
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans"
"orYearly": "or {price}/year",
"yearlySave": "· 2 months free",
"getStarted": "Start Free",
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans",
"how": {
"title": "How it works",
"s1t": "Enter the chassis number",
"s1d": "Paste the 17-digit VIN / chassis number.",
"s2t": "See the parts",
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
"s3t": "Pick the right part",
"s3d": "Compare prices and find the right part in one go — no wrong orders."
},
"faq": {
"title": "Frequently asked questions",
"q1": "How does the free trial work?",
"a1": "When you sign up you get the Full Package free for 30 days — unlimited access to every brand. No credit card required.",
"q2": "Do I need a credit card?",
"a2": "No. We don't ask for card details to start the trial. You're only charged when you choose to move to a paid plan.",
"q3": "Can I cancel anytime?",
"a3": "Yes. Cancel your subscription in one click; your access continues until the end of the period you've paid for.",
"q4": "Can I get a refund?",
"a4": "If you're not satisfied within 7 days of payment, we'll refund you.",
"q5": "Can I change my plan or brands later?",
"a5": "Yes. You can upgrade, downgrade, or change the brands you have access to at any time.",
"q6": "Is payment secure?",
"a6": "Payments are processed on Stripe's PCI-DSS Level 1 infrastructure; we never see your card details. All data is stored in KVKK compliance."
}
},
"language": {
"tr": "Türkçe",

View File

@@ -433,12 +433,40 @@
},
"pricing": {
"title": "Fiyatlandırma",
"heroTitle": "Doğru parçayı saniyede bul",
"heroSubtitle": "Planını seç, 30 gün boyunca ücretsiz dene. Yanlış parça siparişine son.",
"trialFree": "30 gün ücretsiz",
"subtitle": "İhtiyacınıza uygun planı seçin.",
"metaDescription": "200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
"popular": "Popüler",
"orYearly": "veya {price} TL/yıl",
"getStarted": "Başla",
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları"
"orYearly": "veya {price}/yıl",
"yearlySave": "· 2 ay bedava",
"getStarted": "Ücretsiz Başla",
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
"how": {
"title": "Nasıl çalışır",
"s1t": "Şase numarasını gir",
"s1d": "17 haneli VIN/şase numarasını yapıştır.",
"s2t": "Parçaları gör",
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
"s3t": "Doğru parçayı seç",
"s3d": "Fiyatları karşılaştır, doğru parçayı tek seferde bul — yanlış sipariş yok."
},
"faq": {
"title": "Sık sorulan sorular",
"q1": "Ücretsiz deneme nasıl çalışır?",
"a1": "Kayıt olduğunda 30 gün boyunca Full Paket'i ücretsiz kullanırsın — tüm markalara sınırsız erişim. Kredi kartı gerekmez.",
"q2": "Kredi kartı gerekiyor mu?",
"a2": "Hayır. Denemeye başlamak için kart bilgisi istemiyoruz. Ödeme yalnızca ücretli bir plana geçmeyi seçtiğinde alınır.",
"q3": "İstediğim zaman iptal edebilir miyim?",
"a3": "Evet. Aboneliğini tek tıkla iptal edebilirsin; ödediğin dönemin sonuna kadar erişimin devam eder.",
"q4": "Param iade edilir mi?",
"a4": "Ödemeden sonraki 7 gün içinde memnun kalmazsan ücretini iade ediyoruz.",
"q5": "Sonradan plan veya marka değiştirebilir miyim?",
"a5": "Evet. Planını dilediğin zaman yükseltebilir, düşürebilir veya erişimindeki markaları değiştirebilirsin.",
"q6": "Ödeme güvenli mi?",
"a6": "Ödemeler Stripe'ın PCI-DSS Seviye 1 altyapısında işlenir; kart bilgilerini biz görmeyiz. Tüm veriler KVKK uyumlu saklanır."
}
},
"language": {
"tr": "Türkçe",

View File

@@ -0,0 +1,36 @@
/**
* Tests the pricing FAQ — the objection-handling section that fills the
* previously-empty space below the cards (trial, credit card, cancel,
* refund, plan/brand change, payment security).
*/
import { render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { PricingFaq } from "@/routes/pricing";
describe("PricingFaq", () => {
test("renders the title and all six question/answer pairs", () => {
const { container } = render(<PricingFaq />);
const text = container.textContent ?? "";
expect(text).toContain("pricing.faq.title");
for (let i = 1; i <= 6; i++) {
expect(text).toContain(`pricing.faq.q${i}`);
expect(text).toContain(`pricing.faq.a${i}`);
}
});
test("uses native <details> disclosures (one per question)", () => {
const { container } = render(<PricingFaq />);
expect(container.querySelectorAll("details")).toHaveLength(6);
expect(container.querySelectorAll("summary")).toHaveLength(6);
});
});

View File

@@ -0,0 +1,30 @@
/**
* Tests the pricing "Nasıl çalışır" 3-step band that gives cold visitors
* product context (enter VIN -> see parts -> pick the right part).
*/
import { render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { PricingHowItWorks } from "@/routes/pricing";
describe("PricingHowItWorks", () => {
test("renders the title and all three numbered steps", () => {
const { container } = render(<PricingHowItWorks />);
const text = container.textContent ?? "";
expect(text).toContain("pricing.how.title");
for (let i = 1; i <= 3; i++) {
expect(text).toContain(`pricing.how.s${i}t`);
expect(text).toContain(`pricing.how.s${i}d`);
expect(text).toContain(String(i)); // numbered badge
}
});
});

View File

@@ -0,0 +1,21 @@
/**
* Guards that the public pricing page emphasises the same plan as the in-app
* subscription page. The "Popüler" badge was moved from 2 Marka to Full Paket
* so both surfaces tell one story.
*/
import { describe, expect, test } from "vitest";
import { plans } from "@/routes/pricing";
describe("pricing plans", () => {
test("Full Paket is the only popular plan (aligned with subscription)", () => {
expect(plans.find((p) => p.key === "full")?.popular).toBe(true);
expect(plans.find((p) => p.key === "brand2")?.popular).toBeFalsy();
expect(plans.filter((p) => p.popular).map((p) => p.key)).toEqual(["full"]);
});
test("still lists all four tiers in ascending brand order", () => {
expect(plans.map((p) => p.key)).toEqual(["brand1", "brand2", "brand3", "full"]);
});
});

View File

@@ -15,7 +15,7 @@ vi.mock("@/lib/i18n", () => ({
}),
}));
import { CredibilityStrip } from "@/routes/dashboard/subscription/index";
import { CredibilityStrip } from "@/components/credibility-strip";
describe("CredibilityStrip", () => {
test("renders all five product-fact items", () => {

View File

@@ -1,3 +1,4 @@
import { CredibilityStrip } from "@/components/credibility-strip";
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
@@ -30,22 +31,18 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AlertTriangle,
ArrowRight,
Boxes,
Check,
CheckCircle2,
Clock,
CreditCard,
Crown,
Hash,
Info,
Layers,
Loader2,
Minus,
Pencil,
ShieldCheck,
Sparkles,
X,
Zap,
} from "lucide-react";
import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
@@ -957,29 +954,6 @@ function StepCard({
);
}
export function CredibilityStrip() {
const { t } = useTranslation();
const items = [
{ icon: Layers, key: "brands" },
{ icon: Boxes, key: "parts" },
{ icon: Hash, key: "oem" },
{ icon: Zap, key: "speed" },
{ icon: ShieldCheck, key: "security" },
] as const;
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-2xl border border-border bg-muted/20 px-5 py-4 sm:grid-cols-3 lg:grid-cols-5">
{items.map(({ icon: Icon, key }) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Icon className="h-4 w-4 shrink-0 text-primary" />
<span className="font-medium leading-5 text-foreground/85">
{t(`subscription.credibility.${key}`)}
</span>
</div>
))}
</div>
);
}
export function PlanHelperHint() {
const { t } = useTranslation();
return (

View File

@@ -1,18 +1,19 @@
import { usePageMeta } from "@/hooks/use-page-meta";
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 { 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 } from "lucide-react";
import { Check, ChevronDown, Info } from "lucide-react";
export const Route = createFileRoute("/pricing")({
component: PricingPage,
});
const plans = [
export const plans = [
{
key: "brand1",
brandLimit: 1,
@@ -25,7 +26,6 @@ const plans = [
brandLimit: 2,
priceMonthly: 35000,
priceYearly: 350000,
popular: true,
features: ["brandSelection", "vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
},
{
@@ -40,6 +40,7 @@ const plans = [
brandLimit: FULL_PLAN_BRAND_LIMIT,
priceMonthly: 99900,
priceYearly: 999000,
popular: true,
features: [
"allBrands",
"vinSearch",
@@ -65,12 +66,33 @@ function PricingPage() {
<SiteHeader />
<main id="main-content" className="container mx-auto px-4 py-24">
<div className="text-center">
<h1 className="text-4xl font-bold">{t("pricing.title")}</h1>
<p className="mt-4 text-lg text-muted-foreground">{t("pricing.subtitle")}</p>
<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">
{[
"pricing.trialFree",
"subscription.trustNoCard",
"subscription.trustCancelAnytime",
"subscription.trustRefund",
].map((k) => (
<span key={k} className="flex items-center gap-1.5">
<Check className="h-4 w-4 text-primary" />
{t(k)}
</span>
))}
</div>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<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>
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{plans.map((plan) => (
<Card
key={plan.key}
@@ -91,9 +113,8 @@ function PricingPage() {
<span className="text-muted-foreground">{t("common.perMonth")}</span>
</div>
<p className="text-sm text-muted-foreground">
{t("pricing.orYearly", {
price: formatTRY(plan.priceYearly).replace(/[₺\s]/g, "").trim(),
})}
{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) => {
@@ -118,7 +139,61 @@ function PricingPage() {
</Card>
))}
</div>
<div className="mx-auto mt-12 max-w-5xl">
<CredibilityStrip />
</div>
<PricingHowItWorks />
<PricingFaq />
</main>
</div>
);
}
export function PricingHowItWorks() {
const { t } = useTranslation();
const steps = [1, 2, 3] as const;
return (
<section className="mx-auto mt-20 max-w-5xl">
<h2 className="text-center text-2xl font-bold">{t("pricing.how.title")}</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-3">
{steps.map((i) => (
<div key={i} className="rounded-2xl border border-border bg-muted/20 p-6 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary font-bold text-primary-foreground">
{i}
</div>
<h3 className="mt-4 font-semibold">{t(`pricing.how.s${i}t`)}</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t(`pricing.how.s${i}d`)}
</p>
</div>
))}
</div>
</section>
);
}
export function PricingFaq() {
const { t } = useTranslation();
const ids = [1, 2, 3, 4, 5, 6] as const;
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">
{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>
);
}