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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Tests for TrialProgressBanner — the calm, persistent trial-status strip
|
||||
* shown for days 8–30 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user