polish(web): pricing zigzag timeline, FAQ JSON-LD, trust icon variety, feature matrix sticky thead

Pricing page
- "Nasıl çalışır" now renders as a vertical timeline with alternating sides on
  md+ and a connecting rail — no more 3-equal-cards AI tell
- Hero trust strip swaps four identical Check icons for distinct ones
  (CalendarClock / CreditCard / RotateCcw / Check) so each promise reads on
  its own
- FAQ section emits FAQPage JSON-LD for rich SERP results (free SEO upside;
  no UI cost)

Subscription page
- FeatureMatrix gets sticky thead + a right-fade gradient on narrow viewports
  so users can tell more columns lie offscreen
This commit is contained in:
2026-05-31 23:12:21 +03:00
parent 1ea53920b0
commit b8bbdbda67
2 changed files with 153 additions and 90 deletions

View File

@@ -2069,75 +2069,85 @@ export function FeatureMatrix() {
return (
<div>
<h3 className="mb-4 text-xl font-semibold leading-8">{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-semibold leading-5 text-muted-foreground" />
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
plan.popular ? "text-primary" : "text-foreground"
}`}
>
<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">
{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 leading-5">
{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")}
/>
{/* Right-fade gradient hints horizontal scroll on narrow viewports.
Sticky thead keeps plan names + prices in view while the rows scroll. */}
<div className="relative">
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b bg-muted/50">
<th className="w-48 px-4 py-3 text-left font-semibold leading-5 text-muted-foreground" />
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
plan.popular ? "text-primary" : "text-foreground"
}`}
>
<div className="flex flex-col items-center gap-1">
{plan.popular && (
<Badge className="px-1.5 py-0 text-[10px]">
{t("subscription.popular")}
</Badge>
)}
</td>
);
})}
<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>
))}
</tbody>
</table>
</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">
{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 leading-5">
{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
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent sm:hidden"
/>
</div>
</div>
);

View File

@@ -8,7 +8,15 @@ import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowDown, Check, ChevronDown, Info } from "lucide-react";
import {
ArrowDown,
CalendarClock,
Check,
ChevronDown,
CreditCard,
Info,
RotateCcw,
} from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/pricing")({
@@ -90,14 +98,14 @@ function PricingPage() {
<p className="mt-4 text-lg text-muted-foreground">{t("pricing.heroSubtitle")}</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
{[
"pricing.trialFree",
"subscription.trustNoCard",
"subscription.trustCancelAnytime",
"subscription.trustRefund",
].map((k) => (
<span key={k} className="flex items-center gap-1.5">
<Check className="h-4 w-4 text-primary" />
{t(k)}
{ key: "pricing.trialFree", icon: CalendarClock },
{ key: "subscription.trustNoCard", icon: CreditCard },
{ key: "subscription.trustCancelAnytime", icon: RotateCcw },
{ key: "subscription.trustRefund", icon: Check },
].map(({ key, icon: Icon }) => (
<span key={key} className="flex items-center gap-1.5">
<Icon className="h-4 w-4 text-primary" />
{t(key)}
</span>
))}
</div>
@@ -298,21 +306,50 @@ export function PricingHowItWorks() {
const { t } = useTranslation();
const steps = [1, 2, 3] as const;
return (
<section className="mx-auto mt-20 max-w-5xl">
<section className="mx-auto mt-20 max-w-4xl">
<h2 className="text-center text-2xl font-bold">{t("pricing.how.title")}</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-3">
{steps.map((i) => (
<div key={i} className="rounded-2xl border border-border bg-muted/20 p-6 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary font-bold text-primary-foreground">
{i}
</div>
<h3 className="mt-4 font-semibold">{t(`pricing.how.s${i}t`)}</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t(`pricing.how.s${i}d`)}
</p>
</div>
))}
</div>
{/* Vertical timeline rail (md+) ditches the "3 equal cards" cliché.
Each step alternates sides; the rail connects them. */}
<ol className="relative mt-10 space-y-10 md:space-y-12">
<div
aria-hidden="true"
className="absolute left-5 top-2 bottom-2 hidden w-px bg-gradient-to-b from-primary/40 via-primary/20 to-transparent md:left-1/2 md:block md:-translate-x-1/2"
/>
{steps.map((i) => {
const isLeft = i % 2 === 1;
return (
<li
key={i}
className={`relative md:grid md:grid-cols-2 md:items-center md:gap-10 ${
isLeft ? "" : "md:[&>*:first-child]:order-2"
}`}
>
<div className={`md:px-2 ${isLeft ? "md:text-right" : "md:text-left"}`}>
<div className="mb-3 flex items-center gap-3 md:hidden">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{i}
</div>
<h3 className="font-semibold leading-6">{t(`pricing.how.s${i}t`)}</h3>
</div>
<h3 className="hidden text-lg font-semibold leading-7 md:block">
{t(`pricing.how.s${i}t`)}
</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{t(`pricing.how.s${i}d`)}
</p>
</div>
<div className="hidden md:flex md:items-center md:justify-center">
<div
aria-hidden="true"
className="flex h-12 w-12 items-center justify-center rounded-full border border-primary/30 bg-background text-base font-bold text-primary shadow-sm"
>
{i}
</div>
</div>
</li>
);
})}
</ol>
</section>
);
}
@@ -320,6 +357,17 @@ export function PricingHowItWorks() {
export function PricingFaq() {
const { t } = useTranslation();
const ids = [1, 2, 3, 4, 5, 6] as const;
// Emit FAQPage JSON-LD so Google can render rich-result Q/A blocks in SERP
// — pure SEO upside, no UI cost. Renders inert <script type=application/ld+json>.
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: ids.map((i) => ({
"@type": "Question",
name: t(`pricing.faq.q${i}`),
acceptedAnswer: { "@type": "Answer", text: t(`pricing.faq.a${i}`) },
})),
};
return (
<section className="mx-auto mt-20 max-w-3xl">
<h2 className="text-center text-2xl font-bold">{t("pricing.faq.title")}</h2>
@@ -336,6 +384,11 @@ export function PricingFaq() {
</details>
))}
</div>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: structured-data payload, content is from our own i18n bundle. */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</section>
);
}