dev #50
32
apps/web/src/components/credibility-strip.tsx
Normal file
32
apps/web/src/components/credibility-strip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
36
apps/web/src/routes/__tests__/pricing-faq.test.tsx
Normal file
36
apps/web/src/routes/__tests__/pricing-faq.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
30
apps/web/src/routes/__tests__/pricing-how.test.tsx
Normal file
30
apps/web/src/routes/__tests__/pricing-how.test.tsx
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
21
apps/web/src/routes/__tests__/pricing-plans.test.tsx
Normal file
21
apps/web/src/routes/__tests__/pricing-plans.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user