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>
This commit is contained in:
soroush.asadi
2026-06-12 08:23:10 +03:30
parent 1ebde6b15c
commit 05400947e4
8 changed files with 237 additions and 34 deletions
+6 -6
View File
@@ -171,27 +171,27 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-xl font-semibold text-white">{config.title}</h1> <h1 className="text-xl font-semibold text-white">{config.title}</h1>
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>} {config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex w-full items-center gap-2 sm:w-auto">
<input <input
className="w-52 rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500" className="w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500 sm:w-52"
placeholder="جستجو…" value={query} placeholder="جستجو…" value={query}
onChange={(e) => { setQuery(e.target.value); setPage(1); }} onChange={(e) => { setQuery(e.target.value); setPage(1); }}
/> />
{config.canCreate && config.fields && ( {config.canCreate && config.fields && (
<button className={btn} onClick={openCreate}>+ مورد جدید</button> <button className={`${btn} shrink-0 whitespace-nowrap`} onClick={openCreate}>+ مورد جدید</button>
)} )}
</div> </div>
</div> </div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>} {error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
<div className={`${card} overflow-hidden`}> <div className={`${card} overflow-x-auto`}>
<table className="w-full text-sm"> <table className="w-full min-w-[640px] text-sm">
<thead> <thead>
<tr className="border-b border-[#1e2235] text-start text-xs text-gray-500"> <tr className="border-b border-[#1e2235] text-start text-xs text-gray-500">
{config.columns.map((c) => ( {config.columns.map((c) => (
+13 -9
View File
@@ -1,4 +1,5 @@
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar"; import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
import { DashboardSidebarDrawer } from "@/components/dashboard/DashboardSidebarDrawer";
interface DashboardShellProps { interface DashboardShellProps {
userEmail: string; userEmail: string;
@@ -16,14 +17,17 @@ export function DashboardShell({
children, children,
}: DashboardShellProps) { }: DashboardShellProps) {
return ( return (
<div className="flex min-h-screen bg-neutral-50"> <DashboardSidebarDrawer
<DashboardSidebar sidebar={
userEmail={userEmail} <DashboardSidebar
userName={userName} userEmail={userEmail}
userId={userId} userName={userName}
avatarUrl={avatarUrl} userId={userId}
/> avatarUrl={avatarUrl}
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div> />
</div> }
>
{children}
</DashboardSidebarDrawer>
); );
} }
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { LogoMark } from "@/components/ui/LogoMark";
import { cn } from "@/lib/utils";
/**
* Dashboard layout shell with a responsive sidebar: a normal static column on
* desktop (lg+), and an off-canvas drawer with a hamburger top bar on mobile.
*
* The closed-state transform is applied only via `max-lg:` so there is no `lg:`
* transform to conflict with — this sidesteps the RTL/`lg:` specificity trap
* (see AdminShell) and works for both fa (RTL) and en (LTR).
*/
export function DashboardSidebarDrawer({
sidebar,
children,
}: {
sidebar: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// Close the drawer after navigating (the dashboard layout persists across routes).
useEffect(() => setOpen(false), [pathname]);
return (
<div className="flex min-h-screen bg-neutral-50">
<div
className={cn(
"shrink-0 transition-transform max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:z-50",
open ? "max-lg:translate-x-0" : "max-lg:-translate-x-full max-lg:rtl:translate-x-full",
)}
>
{sidebar}
</div>
{open && (
<div
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
onClick={() => setOpen(false)}
aria-hidden
/>
)}
<div className="flex min-h-screen min-w-0 flex-1 flex-col">
{/* Mobile top bar with hamburger */}
<div className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-gray-100 bg-white/95 px-4 backdrop-blur lg:hidden">
<button
type="button"
onClick={() => setOpen(true)}
aria-label="menu"
className="rounded-lg border border-gray-200 p-1.5 text-neutral-600 hover:bg-neutral-100"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
<LogoMark size={28} />
<span className="font-heading text-base font-bold text-neutral-900">FlatRender</span>
</div>
{children}
</div>
</div>
);
}
+3 -3
View File
@@ -88,11 +88,11 @@ export function Hero({ className, config }: HeroProps) {
<motion.div <motion.div
variants={fadeUp} variants={fadeUp}
className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4" className="mx-auto mt-8 flex w-full max-w-xs flex-col items-center justify-center gap-3 sm:max-w-none sm:flex-row sm:gap-4"
> >
<Button <Button
size="lg" size="lg"
className="h-12 min-w-[11rem] rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90" className="h-12 w-full rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90 sm:w-auto sm:min-w-[11rem]"
asChild asChild
> >
<Link href={ctaHref || "/auth?tab=sign-up"}>{ctaLabel ?? t("cta")}</Link> <Link href={ctaHref || "/auth?tab=sign-up"}>{ctaLabel ?? t("cta")}</Link>
@@ -100,7 +100,7 @@ export function Hero({ className, config }: HeroProps) {
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
className="h-12 min-w-[11rem] rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light" className="h-12 w-full rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light sm:w-auto sm:min-w-[11rem]"
asChild asChild
> >
<Link href={browseHref || "#templates"}>{browseLabel ?? t("browse")}</Link> <Link href={browseHref || "#templates"}>{browseLabel ?? t("browse")}</Link>
+102 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Fragment } from "react"; import { Fragment, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -10,6 +10,7 @@ import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButt
import { import {
PricingCompareFeatureLabel, PricingCompareFeatureLabel,
PricingCompareValueCell, PricingCompareValueCell,
PricingCompareValueInline,
} from "@/components/sections/PricingCompareValue"; } from "@/components/sections/PricingCompareValue";
import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data"; import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data";
import { import {
@@ -128,7 +129,12 @@ export function PricingCompareTable({
if (!lite || !pro || !business) return null; if (!lite || !pro || !business) return null;
return ( return (
<div className="mx-auto w-full max-w-5xl overflow-x-auto rounded-2xl border border-gray-100 bg-white shadow-sm"> <>
{/* 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"> <table className="w-full min-w-[760px] border-collapse">
<thead className="sticky top-0 z-10 bg-white"> <thead className="sticky top-0 z-10 bg-white">
<tr className="border-b border-gray-100"> <tr className="border-b border-gray-100">
@@ -184,6 +190,100 @@ export function PricingCompareTable({
))} ))}
</tbody> </tbody>
</table> </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> </div>
); );
} }
+11 -13
View File
@@ -29,24 +29,22 @@ interface PricingCompareValueCellProps {
highlighted?: boolean; highlighted?: boolean;
} }
/** Renders a compare value (✓ / / text) without a table cell, for the mobile card view. */
export function PricingCompareValueInline({ value }: { value: CompareValue }) {
if (value === true) return <Check className="h-4 w-4 text-blue-600" aria-hidden />;
if (value === false) return <Minus className="h-4 w-4 text-gray-300" aria-hidden />;
return <span className="text-sm text-gray-700">{value}</span>;
}
export function PricingCompareValueCell({ export function PricingCompareValueCell({
value, value,
highlighted = false, highlighted = false,
}: PricingCompareValueCellProps) { }: PricingCompareValueCellProps) {
return ( return (
<td <td className={cn("px-4 py-3 text-center", highlighted && "bg-blue-50/30")}>
className={cn( <span className="mx-auto inline-flex items-center justify-center">
"px-4 py-3 text-center", <PricingCompareValueInline value={value} />
highlighted && "bg-blue-50/30" </span>
)}
>
{value === true ? (
<Check className="mx-auto h-4 w-4 text-blue-600" aria-hidden />
) : value === false ? (
<Minus className="mx-auto h-4 w-4 text-gray-300" aria-hidden />
) : (
<span className="text-sm text-gray-700">{value}</span>
)}
</td> </td>
); );
} }
@@ -44,6 +44,12 @@ const SIDEBAR_CATEGORIES: {
{ id: "music", labelKey: "categoryMusic", icon: Music2 }, { id: "music", labelKey: "categoryMusic", icon: Music2 },
]; ];
/** id + labelKey only — shared with the mobile category chip row. */
export const VIDEO_SIDEBAR_CATEGORY_IDS: { id: VideoSidebarCategoryId; labelKey: string }[] =
SIDEBAR_CATEGORIES.map(({ id, labelKey }) => ({ id, labelKey }));
export const VIDEO_CATEGORY_NS = "auto.componentsTemplatesVideoVideoTemplatesCategorySidebar";
interface VideoTemplatesCategorySidebarProps { interface VideoTemplatesCategorySidebarProps {
activeCategory: VideoSidebarCategoryId; activeCategory: VideoSidebarCategoryId;
onCategoryChange: (id: VideoSidebarCategoryId) => void; onCategoryChange: (id: VideoSidebarCategoryId) => void;
@@ -5,7 +5,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { VideoTemplatesCarouselRow } from "@/components/templates/video/VideoTemplatesCarouselRow"; import { VideoTemplatesCarouselRow } from "@/components/templates/video/VideoTemplatesCarouselRow";
import { VideoTemplatesCategorySidebar } from "@/components/templates/video/VideoTemplatesCategorySidebar"; import {
VideoTemplatesCategorySidebar,
VIDEO_SIDEBAR_CATEGORY_IDS,
VIDEO_CATEGORY_NS,
} from "@/components/templates/video/VideoTemplatesCategorySidebar";
import { VideoTemplatesHero } from "@/components/templates/video/VideoTemplatesHero"; import { VideoTemplatesHero } from "@/components/templates/video/VideoTemplatesHero";
import { import {
VideoTemplatesToolbar, VideoTemplatesToolbar,
@@ -40,6 +44,7 @@ export function VideoTemplatesPageContent({
initialCatalog, initialCatalog,
}: VideoTemplatesPageContentProps = {}) { }: VideoTemplatesPageContentProps = {}) {
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesPageContent"); const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesPageContent");
const tCat = useTranslations(VIDEO_CATEGORY_NS);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const categoryParam = searchParams.get("category"); const categoryParam = searchParams.get("category");
@@ -146,6 +151,27 @@ export function VideoTemplatesPageContent({
onSortByChange={setSortBy} onSortByChange={setSortBy}
/> />
{/* Mobile category chips (the sidebar is desktop-only) */}
<div className="mt-4 flex gap-2 overflow-x-auto pb-1 lg:hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{VIDEO_SIDEBAR_CATEGORY_IDS.map((c) => {
const active = sidebarCategory === c.id;
return (
<button
key={c.id}
type="button"
onClick={() => setSidebarCategory(c.id)}
className={`shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{tCat(c.labelKey)}
</button>
);
})}
</div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="mt-12 rounded-xl border border-dashed border-gray-200 bg-white px-6 py-16 text-center"> <div className="mt-12 rounded-xl border border-dashed border-gray-200 bg-white px-6 py-16 text-center">
<p className="font-heading text-lg font-semibold text-gray-900"> <p className="font-heading text-lg font-semibold text-gray-900">