Files
flatrender/src/components/sections/PricingCompareTable.tsx
T
soroush.asadi 05400947e4 feat(responsive): mobile fixes for pricing, dashboard, admin, templates, hero
- PricingCompareTable: wide 4-col table is hidden on mobile; new tab-per-plan card
  view (Lite/Pro/Business) so pricing fits a phone. Extracted PricingCompareValueInline.
- Dashboard: sidebar becomes an off-canvas drawer on mobile (hamburger top bar +
  overlay, closes on navigation) via DashboardSidebarDrawer; static column on lg+.
  RTL/LTR safe (max-lg: transforms avoid the lg:/rtl: specificity trap).
- AdminResource: search/add row stacks on mobile (w-full sm:w-52), tables scroll
  horizontally (overflow-x-auto + min-w) instead of clipping.
- Templates: added a mobile category chip row (lg:hidden) since the category
  sidebar is desktop-only; exported VIDEO_SIDEBAR_CATEGORY_IDS.
- Hero: CTAs full-width on mobile, auto width on sm+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 08:23:10 +03:30

290 lines
9.8 KiB
TypeScript

"use client";
import { Fragment, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButton";
import {
PricingCompareFeatureLabel,
PricingCompareValueCell,
PricingCompareValueInline,
} from "@/components/sections/PricingCompareValue";
import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data";
import {
COMPARE_ANNUAL_SAVINGS_BADGE,
COMPARE_SECTIONS,
getCompareAtPrice,
getDisplayPrice,
PRICING_TIERS,
} from "@/components/sections/pricing-data";
import { Button } from "@/components/ui/button";
import type { PaidPlanId } from "@/lib/plans";
import { cn } from "@/lib/utils";
interface PricingCompareTableProps {
billing: BillingPeriod;
onBillingChange: (billing: BillingPeriod) => void;
}
function SavingsArrowIcon() {
return (
<svg
width="28"
height="20"
viewBox="0 0 28 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-blue-600"
aria-hidden
>
<path
d="M2 14C8 6 14 4 22 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M18 4L23 6L21 11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlanHeaderCell({
tier,
billing,
}: {
tier: PricingTier;
billing: BillingPeriod;
}) {
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
const highlighted = tier.highlighted ?? false;
const isStripePlan = tier.id === "pro" || tier.id === "business";
return (
<th
className={cn(
"px-4 pb-4 pt-6 align-top",
highlighted && "bg-blue-50/30"
)}
>
{highlighted ? (
<span className="mb-2 inline-block rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
{t("mostPopular")}
</span>
) : (
<span className="mb-2 block h-5" aria-hidden />
)}
<p className="font-heading text-base font-bold text-neutral-900">
{tier.name}
</p>
<PricingAnimatedPrice
price={getDisplayPrice(tier, billing)}
compareAt={getCompareAtPrice(tier, billing)}
billing={billing}
size="compact"
/>
{isStripePlan ? (
<PricingCheckoutButton
plan={tier.id as PaidPlanId}
billing={billing}
label={tier.cta}
className={cn(
"mt-3 h-9 w-full rounded-lg text-sm font-semibold",
highlighted
? "bg-rf-blue hover:bg-rf-blue/90"
: "border border-gray-300 bg-white text-neutral-800 hover:bg-gray-50"
)}
variant={highlighted ? "default" : "secondary"}
/>
) : (
<Button
variant="outline"
className="mt-3 h-9 w-full rounded-lg border-gray-300 text-sm font-semibold"
asChild
>
<Link href="/auth?tab=sign-up">{tier.cta}</Link>
</Button>
)}
</th>
);
}
export function PricingCompareTable({
billing,
onBillingChange,
}: PricingCompareTableProps) {
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
const lite = PRICING_TIERS.find((t) => t.id === "lite");
const pro = PRICING_TIERS.find((t) => t.id === "pro");
const business = PRICING_TIERS.find((t) => t.id === "business");
if (!lite || !pro || !business) return null;
return (
<>
{/* Mobile: one plan at a time (tabs) — the wide table can't fit a phone. */}
<MobileCompare billing={billing} onBillingChange={onBillingChange} />
{/* Desktop: full comparison table */}
<div className="mx-auto hidden w-full max-w-5xl overflow-x-auto rounded-2xl border border-gray-100 bg-white shadow-sm sm:block">
<table className="w-full min-w-[760px] border-collapse">
<thead className="sticky top-0 z-10 bg-white">
<tr className="border-b border-gray-100">
<th className="w-[38%] px-6 pb-4 pt-6 text-left align-top">
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent sm:text-xl">
{t("compareHeading")}
</h3>
<div className="mt-4 items-start">
<PricingBillingToggle
billing={billing}
onChange={onBillingChange}
layoutId="pricing-compare-billing-pill"
/>
</div>
<p className="mt-3 flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-blue-600">
{t("saveUpTo", { percent: COMPARE_ANNUAL_SAVINGS_BADGE })}
<SavingsArrowIcon />
</p>
</th>
<PlanHeaderCell tier={lite} billing={billing} />
<PlanHeaderCell tier={pro} billing={billing} />
<PlanHeaderCell tier={business} billing={billing} />
</tr>
</thead>
<tbody>
{COMPARE_SECTIONS.map((section) => (
<Fragment key={section.title}>
<tr className="bg-gray-50">
<td
colSpan={4}
className="px-6 py-3 text-xs font-bold uppercase tracking-widest text-gray-500"
>
{section.title}
</td>
</tr>
{section.rows.map((row) => (
<tr
key={`${section.title}-${row.feature}`}
className="border-b border-gray-100 transition-colors hover:bg-gray-50/60"
>
<td className="px-6 py-3">
<PricingCompareFeatureLabel
feature={row.feature}
tooltip={row.tooltip}
/>
</td>
<PricingCompareValueCell value={row.lite} />
<PricingCompareValueCell value={row.pro} highlighted />
<PricingCompareValueCell value={row.business} />
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</>
);
}
const TIER_IDS = ["lite", "pro", "business"] as const;
type CompareTierId = (typeof TIER_IDS)[number];
function MobileCompare({
billing,
onBillingChange,
}: PricingCompareTableProps) {
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
const [active, setActive] = useState<CompareTierId>("pro");
const tier = PRICING_TIERS.find((x) => x.id === active);
if (!tier) return null;
const isStripePlan = tier.id === "pro" || tier.id === "business";
return (
<div className="mx-auto w-full max-w-md sm:hidden">
<div className="text-center">
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent">
{t("compareHeading")}
</h3>
<div className="mt-3 flex justify-center">
<PricingBillingToggle
billing={billing}
onChange={onBillingChange}
layoutId="pricing-compare-billing-pill-mobile"
/>
</div>
</div>
{/* Plan tabs */}
<div className="mt-4 grid grid-cols-3 gap-1 rounded-xl border border-gray-200 bg-gray-50 p-1">
{TIER_IDS.map((id) => {
const x = PRICING_TIERS.find((p) => p.id === id);
if (!x) return null;
return (
<button
key={id}
type="button"
onClick={() => setActive(id)}
className={cn(
"rounded-lg py-2 text-sm font-semibold transition-colors",
active === id ? "bg-rf-blue text-white shadow-sm" : "text-neutral-600 hover:text-neutral-900",
)}
>
{x.name}
</button>
);
})}
</div>
{/* Selected plan price + CTA */}
<div className="mt-4 rounded-xl border border-gray-100 bg-white p-4 text-center shadow-sm">
<PricingAnimatedPrice
price={getDisplayPrice(tier, billing)}
compareAt={getCompareAtPrice(tier, billing)}
billing={billing}
size="compact"
/>
{isStripePlan ? (
<PricingCheckoutButton
plan={tier.id as PaidPlanId}
billing={billing}
label={tier.cta}
className="mt-3 h-10 w-full rounded-lg bg-rf-blue text-sm font-semibold hover:bg-rf-blue/90"
/>
) : (
<Button variant="outline" className="mt-3 h-10 w-full rounded-lg border-gray-300 text-sm font-semibold" asChild>
<Link href="/auth?tab=sign-up">{tier.cta}</Link>
</Button>
)}
</div>
{/* Feature sections for the selected plan */}
<div className="mt-4 space-y-4">
{COMPARE_SECTIONS.map((section) => (
<div key={section.title}>
<p className="mb-1.5 px-1 text-xs font-bold uppercase tracking-widest text-gray-500">{section.title}</p>
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100 bg-white">
{section.rows.map((row) => (
<div key={row.feature} className="flex items-center justify-between gap-3 px-4 py-2.5">
<PricingCompareFeatureLabel feature={row.feature} tooltip={row.tooltip} />
<span className="shrink-0">
<PricingCompareValueInline value={row[active]} />
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}