fix(web): render plan names + price in subscription feature matrix headers

The comparison table's column headers were empty <th/> cells (aria-label
only), so sighted users could not tell which column belonged to which plan.
Headers now show plan name + monthly price + the Popüler badge. Added a
"Marka sayısı" row exposing the brand limit (1/2/3/Tümü) — the attribute
that actually differentiates the brand1/brand2/brand3 tiers, which the
matrix previously omitted entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:16:57 +03:00
parent 3926e276fd
commit b32c777951
4 changed files with 108 additions and 4 deletions

View File

@@ -171,6 +171,8 @@
"billingPeriod": "Billing Period",
"planComparison": "Plan Comparison",
"featureMatrix": "Feature Comparison",
"matrixBrandCount": "Brand count",
"brandCountAll": "All",
"selectBrands": "Select Brands",
"selectBrandsDescription": "Choose the brands you want to include in your plan.",
"brandsSelected": "brands selected",

View File

@@ -171,6 +171,8 @@
"billingPeriod": "Fatura Dönemi",
"planComparison": "Plan Karşılaştırması",
"featureMatrix": "Özellik Karşılaştırması",
"matrixBrandCount": "Marka sayısı",
"brandCountAll": "Tümü",
"selectBrands": "Marka Seçin",
"selectBrandsDescription": "Planınıza dahil etmek istediğiniz markaları seçin.",
"brandsSelected": "marka seçildi",

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

@@ -1761,7 +1761,7 @@ function DowngradeOfferDialog({
);
}
function FeatureMatrix() {
export function FeatureMatrix() {
const { t } = useTranslation();
return (
<div>
@@ -1774,15 +1774,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">