Merge pull request 'dev' (#49) from dev into main

Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
2026-05-25 12:56:12 +00:00
10 changed files with 605 additions and 17 deletions

View File

@@ -123,10 +123,13 @@
},
"subscription": {
"title": "Subscription",
"subtitle": "Pick a plan, choose your brands, complete payment — all on one page.",
"subtitle": "Find the right part in seconds — pick a plan and start searching.",
"step": "Step",
"selected": "Selected",
"tapToSelect": "Select",
"perDay": "≈ {amount}/day",
"yearlyFreeMonths": "2 months free with yearly billing",
"yearlyGiftBadge": "+2 months free",
"selectExactBrands": "Please select exactly {count} brands.",
"steps": {
"plan": {
@@ -171,6 +174,16 @@
"billingPeriod": "Billing Period",
"planComparison": "Plan Comparison",
"featureMatrix": "Feature Comparison",
"matrixBrandCount": "Brand count",
"brandCountAll": "All",
"planHelper": "Which plan should I pick? Choose the number of brands you work with. For 4+ brands, the Full Package is cheaper per brand.",
"credibility": {
"brands": "50+ brands",
"parts": "1M+ parts",
"oem": "OEM + alternative codes",
"speed": "Results in seconds",
"security": "KVKK compliant · SSL"
},
"selectBrands": "Select Brands",
"selectBrandsDescription": "Choose the brands you want to include in your plan.",
"brandsSelected": "brands selected",
@@ -220,6 +233,7 @@
"orderSummaryPrice": "Price",
"trialTitle": "30-Day Full Package Trial",
"trialDescription": "Free access to all brands for 30 days. No credit card required.",
"trialHook": "Enter the VIN, find the right part in seconds. All catalogs unlocked for 30 days.",
"startTrial": "Start Free Trial",
"trialStarted": "Your 30-day Full Package trial has started!",
"onboarding": {
@@ -257,6 +271,11 @@
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
},
"trialProgress": {
"title": "{days} days left in your Full Package trial",
"description": "Pick a plan now to keep your access without interruption.",
"cta": "Choose a plan"
},
"pendingBanner": {
"title": "You have a pending payment",
"description": "Checkout for {plan} ({brands}) was left incomplete. Resume it or cancel to pick a different plan.",
@@ -294,7 +313,7 @@
"stripe": {
"secureCheckoutTitle": "Secure payment with 3D Secure",
"secureCheckoutDescription": "Card details are processed on Stripe's PCI-DSS Level 1 infrastructure. Sase never sees them.",
"payButton": "Pay {amount}",
"payButton": "Pay with Credit Card",
"cancelled": "Payment cancelled. You can try again.",
"verifyingTitle": "Verifying your payment",
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",

View File

@@ -123,10 +123,13 @@
},
"subscription": {
"title": "Abonelik",
"subtitle": "Planını seç, markalarını belirle, ödemeni yap. Hepsi tek sayfada.",
"subtitle": "Doğru parçayı saniyede bul — planını seç, hemen aramaya başla.",
"step": "Adım",
"selected": "Seçildi",
"tapToSelect": "Seç",
"perDay": "≈ {amount}/gün",
"yearlyFreeMonths": "Yıllık ödemede 2 ay bedava",
"yearlyGiftBadge": "+2 ay hediye",
"selectExactBrands": "Lütfen tam olarak {count} marka seçin.",
"steps": {
"plan": {
@@ -171,6 +174,16 @@
"billingPeriod": "Fatura Dönemi",
"planComparison": "Plan Karşılaştırması",
"featureMatrix": "Özellik Karşılaştırması",
"matrixBrandCount": "Marka sayısı",
"brandCountAll": "Tümü",
"planHelper": "Hangi planı seçmeliyim? İşine giren marka sayısını seç. 4+ marka için Full Paket marka başına daha avantajlı.",
"credibility": {
"brands": "50+ marka",
"parts": "1M+ parça",
"oem": "OEM + alternatif kodlar",
"speed": "Saniyede sonuç",
"security": "KVKK uyumlu · SSL"
},
"selectBrands": "Marka Seçin",
"selectBrandsDescription": "Planınıza dahil etmek istediğiniz markaları seçin.",
"brandsSelected": "marka seçildi",
@@ -220,6 +233,7 @@
"orderSummaryPrice": "Tutar",
"trialTitle": "30 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"trialHook": "Şase numarasını gir, doğru parçayı saniyede bul. 30 gün boyunca tüm kataloglar açık.",
"startTrial": "Ücretsiz Denemeyi Başlat",
"trialStarted": "30 günlük Full Paket denemeniz başlatıldı!",
"onboarding": {
@@ -257,6 +271,11 @@
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
},
"trialProgress": {
"title": "Full Paket denemende {days} gün kaldı",
"description": "Kesintisiz devam etmek için planını şimdi seç.",
"cta": "Planını seç"
},
"pendingBanner": {
"title": "Bekleyen ödemen var",
"description": "{plan} planı, {brands} için ödeme yarıda kaldı. Devam edebilir veya farklı plan seçmek için vazgeçebilirsin.",
@@ -294,7 +313,7 @@
"stripe": {
"secureCheckoutTitle": "3D Secure ile güvenli ödeme",
"secureCheckoutDescription": "Kart bilgilerin Stripe'ın PCI-DSS Seviye 1 altyapısında işlenir. Sase bu bilgileri görmez.",
"payButton": "{amount} ile öde",
"payButton": "Kredi Kartı ile Öde",
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
"verifyingTitle": "Ödemen doğrulanıyor",
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",

View File

@@ -317,7 +317,11 @@ function DashboardLayout() {
</aside>
{/* ─── Main Content Area ────────────────────────────────────────── */}
<div className="flex flex-1 flex-col">
{/* min-w-0: lets this flex column shrink below its content's intrinsic
width so wide children (e.g. the subscription feature-comparison
table) scroll inside their own overflow-x container instead of
forcing the whole mobile layout viewport wider than the device. */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Top Header Bar */}
<header className="flex h-16 items-center justify-between border-b border-border px-4 sm:px-6">
{/* Left: Mobile menu + User welcome */}

View File

@@ -0,0 +1,34 @@
/**
* Tests for the CredibilityStrip shown to converting users (no-sub / trial /
* expired / pending). It surfaces only real, verifiable product facts — data
* scale, speed and security — NOT fabricated customer counts or testimonials.
*/
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 { CredibilityStrip } from "@/routes/dashboard/subscription/index";
describe("CredibilityStrip", () => {
test("renders all five product-fact items", () => {
const { container } = render(<CredibilityStrip />);
const text = container.textContent ?? "";
for (const key of ["brands", "parts", "oem", "speed", "security"]) {
expect(text).toContain(`subscription.credibility.${key}`);
}
});
test("each item pairs an icon (svg) with its label", () => {
const { container } = render(<CredibilityStrip />);
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBe(5);
});
});

View File

@@ -0,0 +1,75 @@
/**
* Regression tests for the FeatureMatrix comparison table.
*
* Guards the FN fix where the table's column headers rendered EMPTY (`<th />`
* with only an aria-label), making it impossible for sighted users to tell
* which column belonged to which plan. The headers must now render the plan
* name + monthly price, and a "brand count" row must expose the one attribute
* that actually differentiates the brand1/brand2/brand3 tiers.
*/
import { render, within } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { FeatureMatrix, plans } from "@/routes/dashboard/subscription/index";
describe("FeatureMatrix column headers (CRO fix)", () => {
test("every plan name is rendered as a visible column header", () => {
const { container } = render(<FeatureMatrix />);
const headerCells = Array.from(container.querySelectorAll("thead th"));
// 1 empty label column + one per plan
expect(headerCells).toHaveLength(plans.length + 1);
for (const plan of plans) {
const nameKey = `subscription.plans.${plan.key}.name`;
const match = headerCells.find((th) => th.textContent?.includes(nameKey));
expect(match, `header for ${plan.key} should render ${nameKey}`).toBeTruthy();
}
});
test("each plan header shows a monthly price (non-empty header text)", () => {
const { container } = render(<FeatureMatrix />);
const planHeaders = Array.from(container.querySelectorAll("thead th")).slice(1);
for (const th of planHeaders) {
// perMonth suffix key proves the price line is present
expect(th.textContent).toContain("common.perMonth");
}
});
test("popular plan header carries the popular badge", () => {
const { container } = render(<FeatureMatrix />);
const popularPlan = plans.find((p) => p.popular);
expect(popularPlan).toBeTruthy();
const headerCells = Array.from(container.querySelectorAll("thead th"));
const popularHeader = headerCells.find((th) =>
th.textContent?.includes(`subscription.plans.${popularPlan!.key}.name`),
);
expect(popularHeader?.textContent).toContain("subscription.popular");
});
test("brand-count row exposes each plan's brand limit (the real differentiator)", () => {
const { container } = render(<FeatureMatrix />);
const rows = Array.from(container.querySelectorAll("tbody tr"));
const brandRow = rows.find((r) =>
r.querySelector("td")?.textContent?.includes("subscription.matrixBrandCount"),
);
expect(brandRow, "a matrixBrandCount row must exist").toBeTruthy();
const cells = within(brandRow as HTMLElement).getAllByRole("cell");
// cells[0] is the row label; the rest map 1:1 to plans
const valueCells = cells.slice(1);
expect(valueCells).toHaveLength(plans.length);
expect(valueCells[0].textContent).toContain("1"); // brand1
expect(valueCells[1].textContent).toContain("2"); // brand2
expect(valueCells[2].textContent).toContain("3"); // brand3
expect(valueCells[3].textContent).toContain("subscription.brandCountAll"); // full
});
});

View File

@@ -0,0 +1,67 @@
/**
* Tests for the OrderSummary block shown above the Stripe pay button.
*
* Previously the payment step rendered only the pay button — the user had no
* "what am I buying" recap (plan / brands / period / total) at the moment of
* payment, a trust + friction gap. OrderSummary closes it using the
* pre-existing (but unused) orderSummary* i18n keys.
*/
import { render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
vi.mock("@sase/shared", async (orig) => {
const actual = await orig<typeof import("@sase/shared")>();
return { ...actual, formatTRY: (n: number) => `TRY${n}` };
});
import { OrderSummary } from "@/routes/dashboard/subscription/index";
describe("OrderSummary", () => {
test("renders plan, brands, period and total rows", () => {
const { container } = render(
<OrderSummary
planKey="brand2"
period="monthly"
totalAmount={35000}
brandsValue="BMW, Audi"
/>,
);
const text = container.textContent ?? "";
expect(text).toContain("subscription.orderSummary");
expect(text).toContain("subscription.plans.brand2.name");
expect(text).toContain("BMW, Audi");
expect(text).toContain("common.monthly");
expect(text).toContain("TRY35000");
expect(text).toContain("common.perMonth");
});
test("yearly period shows yearly label and /year suffix", () => {
const { container } = render(
<OrderSummary planKey="full" period="yearly" totalAmount={999000} brandsValue="Tümü" />,
);
const text = container.textContent ?? "";
expect(text).toContain("common.yearly");
expect(text).toContain("common.perYear");
expect(text).toContain("TRY999000");
expect(text).not.toContain("common.perMonth");
});
test("uses a description list for accessible label/value pairing", () => {
const { container } = render(
<OrderSummary planKey="brand1" period="monthly" totalAmount={20000} brandsValue="BMW" />,
);
expect(container.querySelector("dl")).not.toBeNull();
expect(container.querySelectorAll("dt").length).toBe(4);
expect(container.querySelectorAll("dd").length).toBe(4);
});
});

View File

@@ -0,0 +1,50 @@
/**
* Tests for the plan decision helper and the trial value hook.
*
* The brand-count plan model is ambiguous ("do I need 1, 2 or 3?"), so a
* helper hint sits above the grid. The trial card gained an outcome-focused
* hook line above its feature list. Both are exported pure components.
*/
import { fireEvent, render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { PlanHelperHint, TrialCtaCard } from "@/routes/dashboard/subscription/index";
describe("PlanHelperHint", () => {
test("renders the plan-helper guidance text", () => {
const { container } = render(<PlanHelperHint />);
expect(container.textContent).toContain("subscription.planHelper");
});
});
describe("TrialCtaCard value hook", () => {
test("renders the outcome hook line alongside title and CTA", () => {
const { container } = render(<TrialCtaCard onStartTrial={vi.fn()} pending={false} />);
const text = container.textContent ?? "";
expect(text).toContain("subscription.trialHook");
expect(text).toContain("subscription.trialTitle");
expect(text).toContain("subscription.startTrial");
});
test("clicking the CTA invokes onStartTrial", () => {
const onStartTrial = vi.fn();
const { getByText } = render(<TrialCtaCard onStartTrial={onStartTrial} pending={false} />);
fireEvent.click(getByText("subscription.startTrial"));
expect(onStartTrial).toHaveBeenCalledTimes(1);
});
test("pending state disables the CTA and shows loading label", () => {
const { getByText, container } = render(<TrialCtaCard onStartTrial={vi.fn()} pending={true} />);
expect(getByText("common.loading")).toBeTruthy();
expect(container.querySelector("button")?.hasAttribute("disabled")).toBe(true);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Tests for TrialProgressBanner — the calm, persistent trial-status strip
* shown for days 830 of a trial. Previously trial users saw no reminder of
* remaining days until the urgent (<=7d) banner appeared, leaving the bulk of
* the trial window with no conversion nudge.
*/
import { fireEvent, render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { TrialProgressBanner } from "@/routes/dashboard/subscription/index";
describe("TrialProgressBanner", () => {
test("renders remaining days, description and CTA", () => {
const { container } = render(<TrialProgressBanner daysRemaining={20} onCta={vi.fn()} />);
const text = container.textContent ?? "";
expect(text).toContain("subscription.trialProgress.title");
expect(text).toContain('"days":20');
expect(text).toContain("subscription.trialProgress.description");
expect(text).toContain("subscription.trialProgress.cta");
});
test("progress bar width reflects elapsed share of a 30-day trial", () => {
// 20 days left of 30 => 10 elapsed => 33%
const { container } = render(<TrialProgressBanner daysRemaining={20} onCta={vi.fn()} />);
const bar = container.querySelector('[style*="width"]') as HTMLElement | null;
expect(bar).not.toBeNull();
expect(bar!.style.width).toBe("33%");
});
test("clamps progress for out-of-range day counts", () => {
const { container } = render(<TrialProgressBanner daysRemaining={40} onCta={vi.fn()} />);
const bar = container.querySelector('[style*="width"]') as HTMLElement;
expect(bar.style.width).toBe("0%");
});
test("CTA click invokes onCta", () => {
const onCta = vi.fn();
const { getByRole } = render(<TrialProgressBanner daysRemaining={15} onCta={onCta} />);
fireEvent.click(getByRole("button"));
expect(onCta).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,70 @@
/**
* Tests for the value-reinforcement additions:
* - per-day price anchor on every plan card
* - "2 months free" note shown only for yearly billing
*
* These reframe the price at the moment of decision (small daily number) and
* make the yearly discount concrete, per the CRO audit.
*/
import { fireEvent, render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string) => key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
vi.mock("@/lib/posthog", () => ({
capture: vi.fn(),
setPeopleProperties: vi.fn(),
}));
import { BillingPeriodToggle, PlanGrid, plans } from "@/routes/dashboard/subscription/index";
describe("per-day price anchor (PlanGrid)", () => {
test("renders a per-day line on every plan card", () => {
const { container } = render(
<PlanGrid
billingPeriod="monthly"
selectedPlanKey={null}
currentPlanKey={null}
activeSubscription={false}
onSelect={vi.fn()}
/>,
);
const perDayNodes = Array.from(container.querySelectorAll("p")).filter((p) =>
p.textContent?.includes("subscription.perDay"),
);
expect(perDayNodes).toHaveLength(plans.length);
});
});
describe("plan emphasis", () => {
test("Full Paket is the only popular plan (not 2 Marka)", () => {
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"]);
});
});
describe("yearly gift badge (BillingPeriodToggle)", () => {
test("shows the +2-month gift badge on the yearly tab regardless of selection", () => {
// The badge advertises the gift on the yearly tab even while monthly is active.
const monthly = render(<BillingPeriodToggle period="monthly" onChange={vi.fn()} />);
expect(monthly.container.textContent).toContain("subscription.yearlyGiftBadge");
monthly.unmount();
const yearly = render(<BillingPeriodToggle period="yearly" onChange={vi.fn()} />);
expect(yearly.container.textContent).toContain("subscription.yearlyGiftBadge");
});
test("toggling fires onChange with the chosen period", () => {
const onChange = vi.fn();
const { getByText } = render(<BillingPeriodToggle period="monthly" onChange={onChange} />);
fireEvent.click(getByText("common.yearly"));
expect(onChange).toHaveBeenCalledWith("yearly");
});
});

View File

@@ -30,17 +30,22 @@ 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";
@@ -112,7 +117,6 @@ export const plans = [
brandLimit: 2,
priceMonthly: 35000,
priceYearly: 350000,
popular: true,
features: ["vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
},
{
@@ -127,6 +131,7 @@ export const plans = [
brandLimit: FULL_PLAN_BRAND_LIMIT,
priceMonthly: 99900,
priceYearly: 999000,
popular: true,
features: [
"allBrands",
"vinSearch",
@@ -180,7 +185,7 @@ export function SubscriptionPage() {
const [step, setStep] = useState<CheckoutStep>("plan");
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("yearly");
// ─── Page-level state ──────────────────────────────────────────────────────
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
@@ -549,6 +554,8 @@ export function SubscriptionPage() {
<p className="text-sm text-muted-foreground">{t("subscription.subtitle")}</p>
</header>
{showStepper && <CredibilityStrip />}
{subscription && hasActiveSub && (
<ActiveSubscriptionCard
subscription={subscription}
@@ -582,6 +589,16 @@ export function SubscriptionPage() {
/>
)}
{trialDaysRemaining !== null && trialDaysRemaining > 7 && (
<TrialProgressBanner
daysRemaining={trialDaysRemaining}
onCta={() => {
capture("trial_progress_cta_clicked", { days_remaining: trialDaysRemaining });
document.getElementById("checkout-flow")?.scrollIntoView({ behavior: "smooth" });
}}
/>
)}
{isPending && subscription && (
<PendingPaymentBanner
planName={subscription.plan?.name ?? ""}
@@ -624,6 +641,7 @@ export function SubscriptionPage() {
}
onEdit={() => setStep("plan")}
>
<PlanHelperHint />
<BillingPeriodToggle period={billingPeriod} onChange={setBillingPeriod} />
<PlanGrid
billingPeriod={billingPeriod}
@@ -939,7 +957,40 @@ function StepCard({
);
}
function BillingPeriodToggle({
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 (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<span>{t("subscription.planHelper")}</span>
</div>
);
}
export function BillingPeriodToggle({
period,
onChange,
}: {
@@ -948,7 +999,7 @@ function BillingPeriodToggle({
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2">
<div className="inline-flex rounded-full border border-border bg-muted/50 p-1">
{(["monthly", "yearly"] as const).map((p) => (
<button
@@ -967,9 +1018,7 @@ function BillingPeriodToggle({
>
{t(p === "monthly" ? "common.monthly" : "common.yearly")}
{p === "yearly" && (
<Badge variant="secondary" className="ml-2 text-[10px]">
-17%
</Badge>
<Badge className="ml-2 text-[10px]">{t("subscription.yearlyGiftBadge")}</Badge>
)}
</button>
))}
@@ -1043,6 +1092,13 @@ export function PlanGrid({
<span className="text-xs text-muted-foreground">
{billingPeriod === "monthly" ? t("common.perMonth") : t("common.perYear")}
</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("subscription.perDay", {
amount: formatTRY(
Math.round(billingPeriod === "monthly" ? price / 30 : price / 365),
),
})}
</p>
</div>
<ul className="space-y-1.5 text-xs text-foreground/85">
{plan.features.map((f) => (
@@ -1083,6 +1139,50 @@ export function PlanGrid({
);
}
export function OrderSummary({
planKey,
period,
totalAmount,
brandsValue,
}: {
planKey: string;
period: "monthly" | "yearly";
totalAmount: number;
brandsValue: string;
}) {
const { t } = useTranslation();
const periodSuffix = period === "yearly" ? t("common.perYear") : t("common.perMonth");
return (
<div className="rounded-2xl border border-border bg-muted/30 p-4">
<p className="mb-3 text-sm font-semibold">{t("subscription.orderSummary")}</p>
<dl className="space-y-2 text-sm">
<div className="flex items-start justify-between gap-4">
<dt className="text-muted-foreground">{t("subscription.orderSummaryPlan")}</dt>
<dd className="text-right font-medium">{t(`subscription.plans.${planKey}.name`)}</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="shrink-0 text-muted-foreground">{t("subscription.orderSummaryBrands")}</dt>
<dd className="text-right font-medium">{brandsValue}</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="text-muted-foreground">{t("subscription.orderSummaryPeriod")}</dt>
<dd className="text-right font-medium">
{period === "yearly" ? t("common.yearly") : t("common.monthly")}
</dd>
</div>
<Separator className="my-1" />
<div className="flex items-baseline justify-between gap-4">
<dt className="font-medium">{t("subscription.orderSummaryPrice")}</dt>
<dd className="text-right text-lg font-bold">
{formatTRY(totalAmount)}
<span className="text-xs font-normal text-muted-foreground">{periodSuffix}</span>
</dd>
</div>
</dl>
</div>
);
}
function PaymentMethodSection({
planKey,
period,
@@ -1095,8 +1195,31 @@ function PaymentMethodSection({
totalAmount: number;
}) {
const { t } = useTranslation();
const isFullPlan = planKey === "full";
const { data: brands } = useQuery({
queryKey: ["brands"],
queryFn: () => api.get<Array<{ id: string; name: string }>>("/brands"),
staleTime: 5 * 60 * 1000,
enabled: !isFullPlan && brandIds.length > 0,
});
const selectedBrandNames = (brands ?? [])
.filter((b) => brandIds.includes(b.id))
.map((b) => b.name);
const brandsValue = isFullPlan
? t("subscription.allBrandsSelected")
: selectedBrandNames.length > 0
? selectedBrandNames.join(", ")
: `${brandIds.length} ${t("subscription.brandsSelected")}`;
return (
<div className="space-y-4">
<OrderSummary
planKey={planKey}
period={period}
totalAmount={totalAmount}
brandsValue={brandsValue}
/>
<StripeCheckoutButton
planKey={planKey}
period={period}
@@ -1332,7 +1455,10 @@ function ActiveSubscriptionCard({
);
}
function TrialCtaCard({ onStartTrial, pending }: { onStartTrial: () => void; pending: boolean }) {
export function TrialCtaCard({
onStartTrial,
pending,
}: { onStartTrial: () => void; pending: boolean }) {
const { t } = useTranslation();
return (
<Card className="relative overflow-hidden border-brand/25 bg-brand/5">
@@ -1349,6 +1475,9 @@ function TrialCtaCard({ onStartTrial, pending }: { onStartTrial: () => void; pen
</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
<p className="text-sm font-medium leading-6 text-foreground">
{t("subscription.trialHook")}
</p>
<ul className="space-y-2 text-sm leading-relaxed">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-foreground/85">
@@ -1485,6 +1614,50 @@ function TrialUrgencyBanner({
);
}
export function TrialProgressBanner({
daysRemaining,
totalDays = 30,
onCta,
}: {
daysRemaining: number;
totalDays?: number;
onCta: () => void;
}) {
const { t } = useTranslation();
const elapsed = Math.min(totalDays, Math.max(0, totalDays - daysRemaining));
const pct = Math.round((elapsed / totalDays) * 100);
return (
<div
aria-live="polite"
className="animate-fade-in-up flex flex-col gap-3 rounded-2xl border border-brand/25 bg-brand/5 px-5 py-4 sm:flex-row sm:items-center"
>
<div className="flex flex-1 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand/15 text-brand">
<Sparkles className="h-4 w-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold leading-5 text-foreground">
{t("subscription.trialProgress.title", { days: daysRemaining })}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t("subscription.trialProgress.description")}
</p>
<div className="mt-2 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-brand/15">
<div
className="h-full rounded-full bg-brand transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
<Button size="sm" className="shrink-0" onClick={onCta}>
{t("subscription.trialProgress.cta")}
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
</Button>
</div>
);
}
interface TrialMutation {
isPending: boolean;
isError: boolean;
@@ -1761,7 +1934,7 @@ function DowngradeOfferDialog({
);
}
function FeatureMatrix() {
export function FeatureMatrix() {
const { t } = useTranslation();
return (
<div>
@@ -1774,15 +1947,40 @@ function FeatureMatrix() {
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center font-semibold leading-5 ${
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
plan.popular ? "text-primary" : "text-foreground"
}`}
aria-label={t(`subscription.plans.${plan.key}.name`)}
/>
>
<div className="flex flex-col items-center gap-1">
{plan.popular && (
<Badge className="px-1.5 py-0 text-[10px]">{t("subscription.popular")}</Badge>
)}
<span>{t(`subscription.plans.${plan.key}.name`)}</span>
<span className="text-xs font-normal text-muted-foreground">
{formatTRY(plan.priceMonthly)}
{t("common.perMonth")}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b bg-muted/40">
<td className="px-4 py-3 font-medium leading-5 text-foreground">
{t("subscription.matrixBrandCount")}
</td>
{plans.map((plan) => (
<td
key={plan.key}
className="px-4 py-3 text-center font-semibold leading-5 text-foreground"
>
{plan.brandLimit === FULL_PLAN_BRAND_LIMIT
? t("subscription.brandCountAll")
: plan.brandLimit}
</td>
))}
</tr>
{ALL_FEATURES.map((feature, i) => (
<tr key={feature} className={i % 2 === 0 ? "bg-background" : "bg-muted/25"}>
<td className="px-4 py-3 font-medium leading-5 text-foreground">