feat(FN-206): add FeatureMatrix component, ALL_FEATURES constant, and i18n keys (+1 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- feat(FN-206): add FeatureMatrix tests and export SubscriptionPage for testing
- feat(FN-206): add FeatureMatrix component, ALL_FEATURES constant, and i18n keys

Files changed:
apps/web/src/messages/en.json                      |   1 +
 apps/web/src/messages/tr.json                      |   1 +
 .../routes/dashboard/subscription/index.test.tsx   | 177 +++++++++++++++++++++
 .../src/routes/dashboard/subscription/index.tsx    |  73 +++++++++
 package.json                                       |   1 +
 5 files changed, 253 insertions(+)

Fusion-Task-Id: FN-206
This commit is contained in:
Fusion
2026-05-12 18:42:33 +00:00
parent b1e75a0771
commit 1eefd931c9
5 changed files with 253 additions and 0 deletions

View File

@@ -140,6 +140,7 @@
"status": "Status",
"billingPeriod": "Billing Period",
"planComparison": "Plan Comparison",
"featureMatrix": "Feature Comparison",
"selectBrands": "Select Brands",
"selectBrandsDescription": "Choose the brands you want to include in your plan.",
"brandsSelected": "brands selected",

View File

@@ -140,6 +140,7 @@
"status": "Durum",
"billingPeriod": "Fatura Dönemi",
"planComparison": "Plan Karşılaştırması",
"featureMatrix": "Özellik Karşılaştırması",
"selectBrands": "Marka Seçin",
"selectBrandsDescription": "Planınıza dahil etmek istediğiniz markaları seçin.",
"brandsSelected": "marka seçildi",

View File

@@ -0,0 +1,177 @@
/**
* Tests for the SubscriptionPage including FN-206 Feature Matrix.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { SubscriptionPage } from "./index";
// ── Mocks ────────────────────────────────────────────────────────────────────
// Mock @/lib/api-client
const apiGet = vi.fn();
vi.mock("@/lib/api-client", () => ({
api: { get: (...args: unknown[]) => apiGet(...args) },
ApiError: class ApiError extends Error {
code?: string;
status?: number;
constructor(message: string, code?: string, status?: number) {
super(message);
this.name = "ApiError";
this.code = code;
this.status = status;
}
},
}));
// Mock @tanstack/react-router
const mockNavigate = vi.fn();
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual<any>("@tanstack/react-router");
return {
...actual,
useNavigate: () => mockNavigate,
createFileRoute: () => (routeOpts: any) => routeOpts,
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
};
});
// Mock @/lib/posthog
vi.mock("@/lib/posthog", () => ({
capture: vi.fn(),
setPeopleProperties: vi.fn(),
}));
// Mock @/lib/faro
vi.mock("@/lib/faro", () => ({
startAction: vi.fn(),
}));
// Mock @/lib/toast
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Mock @/lib/user-settings
vi.mock("@/lib/user-settings", () => ({
getUserSettings: () => ({ theme: "dark" }),
}));
// Mock canvas-confetti
vi.mock("canvas-confetti", () => ({ default: vi.fn() }));
// Mock lazy BrandSelector
vi.mock("@/components/subscription/brand-selector", () => ({
BrandSelector: () => null,
}));
// Mock @remotion/player
vi.mock("@remotion/player", () => ({
Player: () => null,
}));
// Mock OnboardingProgress
vi.mock("@/remotion/OnboardingProgress", () => ({
default: () => null,
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
beforeEach(() => {
vi.clearAllMocks();
});
// ═══════════════════════════════════════════════════════════════════════════════
// FN-206: Feature matrix comparison table
// ═══════════════════════════════════════════════════════════════════════════════
describe("FN-206 — Feature matrix comparison table", () => {
test("renders the feature matrix section heading", async () => {
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
renderWithProviders(<SubscriptionPage />);
await waitFor(() => {
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
});
});
test("renders 5 header cells: 1 empty label column + 4 plan columns", async () => {
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
renderWithProviders(<SubscriptionPage />);
await waitFor(() => {
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
});
const table = document.querySelector("table");
expect(table).toBeInTheDocument();
const headerCells = table?.querySelectorAll("thead th");
expect(headerCells?.length).toBe(5);
});
test("renders 6 feature rows (one per ALL_FEATURES entry)", async () => {
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
renderWithProviders(<SubscriptionPage />);
await waitFor(() => {
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
});
const table = document.querySelector("table");
const rows = table?.querySelectorAll("tbody tr");
expect(rows?.length).toBe(6);
});
test("full plan column (index 4) has a Check for every row", async () => {
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
renderWithProviders(<SubscriptionPage />);
await waitFor(() => {
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
});
const table = document.querySelector("table");
const rows = table?.querySelectorAll("tbody tr");
for (const row of rows ?? []) {
const cells = row.querySelectorAll("td");
// td[0]=label, td[1]=brand1, td[2]=brand2, td[3]=brand3, td[4]=full
const fullCell = cells[4];
expect(fullCell.querySelector('[aria-label="Evet"]')).toBeInTheDocument();
expect(fullCell.querySelector('[aria-label="Hayır"]')).not.toBeInTheDocument();
}
});
test("brand1 column (index 1) shows Minus for prioritySupport (row 4) and oemSearch (row 5)", async () => {
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
renderWithProviders(<SubscriptionPage />);
await waitFor(() => {
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
});
const table = document.querySelector("table");
const rows = table?.querySelectorAll("tbody tr");
// prioritySupport row (index 4)
const brand1PriorityCell = rows[4].querySelectorAll("td")[1];
expect(brand1PriorityCell.querySelector('[aria-label="Hayır"]')).toBeInTheDocument();
// oemSearch row (index 5)
const brand1OemCell = rows[5].querySelectorAll("td")[1];
expect(brand1OemCell.querySelector('[aria-label="Hayır"]')).toBeInTheDocument();
});
});

View File

@@ -31,6 +31,7 @@ import {
CreditCard,
Crown,
Loader2,
Minus,
ShieldCheck,
Sparkles,
} from "lucide-react";
@@ -117,6 +118,15 @@ const plans = [
},
];
const ALL_FEATURES = [
"allBrands",
"vinSearch",
"partsCatalog",
"schemaViewer",
"prioritySupport",
"oemSearch",
] as const;
function formatTRY(amount: number): string {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
@@ -721,6 +731,8 @@ export function SubscriptionPage() {
</div>
</div>
<FeatureMatrix />
{/* Order Summary (shown when a plan is selected) */}
{selectedPlanKey &&
(() => {
@@ -845,3 +857,64 @@ function OnboardingPlayer({
/>
);
}
function FeatureMatrix() {
const { t } = useTranslation();
return (
<div>
<h3 className="mb-4 text-lg font-semibold">{t("subscription.featureMatrix")}</h3>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="w-48 px-4 py-3 text-left font-medium text-muted-foreground" />
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center font-semibold ${
plan.popular ? "text-primary" : "text-foreground"
}`}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 text-[10px]">
{t("subscription.popular")}
</Badge>
)}
{t(`subscription.plans.${plan.key}.name`)}
</th>
))}
</tr>
</thead>
<tbody>
{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 text-foreground">
{t(`subscription.features.${feature}`)}
</td>
{plans.map((plan) => {
const has = (plan.features as readonly string[]).includes(feature);
return (
<td key={plan.key} className="px-4 py-3 text-center">
{has ? (
<Check
className="mx-auto h-4 w-4 text-primary"
aria-label={t("common.yes")}
/>
) : (
<Minus
className="mx-auto h-4 w-4 text-muted-foreground/40"
aria-label={t("common.no")}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}