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
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
177
apps/web/src/routes/dashboard/subscription/index.test.tsx
Normal file
177
apps/web/src/routes/dashboard/subscription/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user