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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user