feat(home): admin-managed homepage section manager (toggle/reorder/edit)

The homepage is now driven by a `home_layout` Website Setting (jsonb) instead of a
hardcoded section stack — zero backend changes, no migration.

- lib/home-layout.ts: section catalog + saved-layout merge + locale-aware config
  reader (`<field>_fa`/`<field>_en`) + public fetchHomeLayout() (falls back to
  defaults when unset/unreachable).
- app/[locale]/page.tsx: renders ordered, enabled sections from the layout, passing
  per-section content overrides.
- sections (Hero/Products/Templates/HowItWorks/Pricing/Testimonials/FAQ): accept an
  optional `config` prop overriding heading/subtitle/CTA, locale-aware, default-safe.
- new HomeSlides + HomeEvents sections render the previously-orphaned admin Slides
  (/v1/slides) and Home Events (/v1/home-events) data.
- admin: HomeSectionsManager (toggle on/off, ↑/↓ reorder, per-section FA/EN content
  editor) at /admin/home, saved via the existing /v1/settings upsert; nav item + i18n.

Verified: a saved layout overrides Hero/Pricing headings and reorders sections;
removing it reverts to the default homepage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 01:21:44 +03:30
parent 1f6c35eb7c
commit b3637cf839
17 changed files with 578 additions and 45 deletions
+1
View File
@@ -376,6 +376,7 @@
"categories": "Categories", "categories": "Categories",
"tags": "Tags", "tags": "Tags",
"fonts": "Fonts", "fonts": "Fonts",
"homePage": "Home Page",
"blogs": "Blog", "blogs": "Blog",
"learn": "Tutorials", "learn": "Tutorials",
"pages": "Pages", "pages": "Pages",
+1
View File
@@ -376,6 +376,7 @@
"categories": "دسته‌بندی‌ها", "categories": "دسته‌بندی‌ها",
"tags": "برچسب‌ها", "tags": "برچسب‌ها",
"fonts": "فونت‌ها", "fonts": "فونت‌ها",
"homePage": "صفحهٔ اصلی",
"blogs": "بلاگ", "blogs": "بلاگ",
"learn": "آموزش‌ها", "learn": "آموزش‌ها",
"pages": "برگه‌ها", "pages": "برگه‌ها",
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { HomeSectionsManager } from "@/components/admin/HomeSectionsManager";
export default function Page() {
return <HomeSectionsManager />;
}
+1
View File
@@ -25,6 +25,7 @@ export default async function AdminLayout({
{ {
title: "محتوا", title: "محتوا",
items: [ items: [
{ href: "/admin/home", label: t("homePage") },
{ href: "/admin/categories", label: t("categories") }, { href: "/admin/categories", label: t("categories") },
{ href: "/admin/templates", label: t("templates") }, { href: "/admin/templates", label: t("templates") },
{ href: "/admin/projects", label: t("projects") }, { href: "/admin/projects", label: t("projects") },
+45 -13
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { FAQ } from "@/components/sections/FAQ";
import { Hero } from "@/components/sections/Hero"; import { Hero } from "@/components/sections/Hero";
import { HomeEvents } from "@/components/sections/HomeEvents";
import { HomeSlides } from "@/components/sections/HomeSlides";
import { HowItWorks } from "@/components/sections/HowItWorks"; import { HowItWorks } from "@/components/sections/HowItWorks";
import { Pricing } from "@/components/sections/Pricing"; import { Pricing } from "@/components/sections/Pricing";
import { ProductsShowcase } from "@/components/sections/ProductsShowcase"; import { ProductsShowcase } from "@/components/sections/ProductsShowcase";
import { TemplateGallery } from "@/components/sections/TemplateGallery"; import { TemplateGallery } from "@/components/sections/TemplateGallery";
import { FAQ } from "@/components/sections/FAQ";
import { Testimonials } from "@/components/sections/Testimonials"; import { Testimonials } from "@/components/sections/Testimonials";
import { createPageMetadata } from "@/lib/metadata"; import { createPageMetadata } from "@/lib/metadata";
import { fetchProjects } from "@/lib/admin-api"; import { fetchProjects, type AdminProject } from "@/lib/admin-api";
import { fetchHomeLayout, type HomeSection } from "@/lib/home-layout";
export const revalidate = 30;
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("auto.appPage"); const t = await getTranslations("auto.appPage");
@@ -20,21 +25,48 @@ export async function generateMetadata(): Promise<Metadata> {
}); });
} }
/** Maps a configured section key → its component, passing admin content overrides. */
function renderSection(section: HomeSection, adminProjects: AdminProject[]) {
const config = section.config ?? {};
switch (section.key) {
case "hero":
return <Hero config={config} />;
case "slides":
return <HomeSlides />;
case "products":
return <ProductsShowcase config={config} />;
case "templates":
return <TemplateGallery adminItems={adminProjects} config={config} />;
case "howItWorks":
return <HowItWorks config={config} />;
case "pricing":
return <Pricing config={config} />;
case "testimonials":
return <Testimonials config={config} />;
case "faq":
return <FAQ config={config} />;
case "events":
return <HomeEvents />;
default:
return null;
}
}
export default async function Home() { export default async function Home() {
// Fetch up to 8 published projects from the admin service. // Layout (which sections, order, on/off, content overrides) is admin-managed via
// Returns an empty array when ADMIN_API_URL is not set or the service // the `home_layout` setting; falls back to sensible defaults when unset.
// is unreachable — TemplateGallery falls back to hardcoded data. const [sections, projects] = await Promise.all([
const { items: adminProjects } = await fetchProjects({ pageSize: 8 }); fetchHomeLayout(),
fetchProjects({ pageSize: 8 }),
]);
return ( return (
<main> <main>
<Hero /> {sections
<ProductsShowcase /> .filter((s) => s.enabled)
<TemplateGallery adminItems={adminProjects} /> .map((s) => (
<HowItWorks /> <div key={s.key}>{renderSection(s, projects.items)}</div>
<Pricing /> ))}
<Testimonials />
<FAQ />
</main> </main>
); );
} }
@@ -0,0 +1,239 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { FileUploadField } from "@/components/admin/FileUploadField";
import {
HOME_LAYOUT_KEY,
mergeLayout,
type HomeSection,
} from "@/lib/home-layout";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-40";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-[11px] font-medium text-gray-500";
type FieldDef = { key: string; label: string; localized?: boolean; type?: "text" | "textarea" | "url" | "image" };
// Editable content per section type. Localized fields store <key>_fa / <key>_en.
const SECTION_META: Record<string, { label: string; note?: string; fields: FieldDef[] }> = {
hero: {
label: "بخش اصلی (Hero)",
fields: [
{ key: "title", label: "عنوان", localized: true },
{ key: "description", label: "توضیح", localized: true, type: "textarea" },
{ key: "ctaLabel", label: "متن دکمهٔ اصلی", localized: true },
{ key: "ctaHref", label: "لینک دکمهٔ اصلی", type: "url" },
{ key: "browseLabel", label: "متن دکمهٔ دوم", localized: true },
{ key: "browseHref", label: "لینک دکمهٔ دوم", type: "url" },
],
},
slides: { label: "اسلایدها", note: "محتوای اسلایدها در بخش «اسلایدها» مدیریت می‌شود.", fields: [] },
products: { label: "محصولات", fields: [{ key: "heading", label: "عنوان", localized: true }] },
templates: { label: "گالری قالب‌ها", fields: [{ key: "heading", label: "عنوان", localized: true }] },
howItWorks: {
label: "چطور کار می‌کند",
fields: [
{ key: "heading", label: "عنوان", localized: true },
{ key: "subtitle", label: "زیرعنوان", localized: true },
],
},
pricing: { label: "تعرفه‌ها", fields: [{ key: "heading", label: "عنوان", localized: true }] },
testimonials: { label: "نظرات کاربران", fields: [{ key: "heading", label: "عنوان", localized: true }] },
faq: {
label: "سوالات متداول",
fields: [
{ key: "heading", label: "عنوان", localized: true },
{ key: "subtitle", label: "زیرعنوان", localized: true },
],
},
events: { label: "بنر رویدادها", note: "محتوای رویدادها در بخش «رویدادهای صفحه اصلی» مدیریت می‌شود.", fields: [] },
};
const metaFor = (key: string) => SECTION_META[key] ?? { label: key, fields: [] };
export function HomeSectionsManager() {
const [sections, setSections] = useState<HomeSection[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/resource/settings/all", { cache: "no-store" });
const data = await res.json();
const rows: Array<{ key: string; value: string }> = Array.isArray(data) ? data : data?.items ?? [];
const row = rows.find((r) => r.key === HOME_LAYOUT_KEY);
let parsed: { sections: HomeSection[] } | null = null;
if (row?.value) {
try {
const v = typeof row.value === "string" ? JSON.parse(row.value) : row.value;
if (v && Array.isArray(v.sections)) parsed = v;
} catch {
parsed = null;
}
}
setSections(mergeLayout(parsed));
} catch {
setError("بارگذاری چیدمان ناموفق بود");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
const move = (i: number, dir: -1 | 1) => {
setSections((prev) => {
const next = [...prev];
const j = i + dir;
if (j < 0 || j >= next.length) return prev;
[next[i], next[j]] = [next[j], next[i]];
return next;
});
};
const toggle = (key: string) =>
setSections((prev) => prev.map((s) => (s.key === key ? { ...s, enabled: !s.enabled } : s)));
const setConfig = (key: string, field: string, value: string) =>
setSections((prev) =>
prev.map((s) => (s.key === key ? { ...s, config: { ...(s.config ?? {}), [field]: value } } : s)),
);
const save = async () => {
setSaving(true);
setError(null);
setMsg(null);
try {
const payload = { sections: sections.map((s, i) => ({ ...s, sort: i })) };
const res = await fetch("/api/admin/resource/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: HOME_LAYOUT_KEY,
value: JSON.stringify(payload),
description: "چیدمان و محتوای صفحهٔ اصلی",
is_secret: false,
}),
});
if (!res.ok) {
const d = await res.json().catch(() => null);
throw new Error(d?.error ?? "ذخیره‌سازی ناموفق بود");
}
setMsg("ذخیره شد — صفحهٔ اصلی به‌روزرسانی شد.");
} catch (e) {
setError(e instanceof Error ? e.message : "ذخیره‌سازی ناموفق بود");
} finally {
setSaving(false);
}
};
const fieldInput = (s: HomeSection, f: FieldDef, suffix: "" | "_fa" | "_en") => {
const ck = `${f.key}${suffix}`;
const val = String(s.config?.[ck] ?? "");
if (f.type === "image") {
return <FileUploadField value={val} onChange={(url) => setConfig(s.key, ck, url)} accept="image/*" />;
}
if (f.type === "textarea") {
return (
<textarea
className={`${inp} min-h-[72px]`}
value={val}
onChange={(e) => setConfig(s.key, ck, e.target.value)}
dir="auto"
/>
);
}
return <input className={inp} value={val} onChange={(e) => setConfig(s.key, ck, e.target.value)} dir="auto" />;
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-white">صفحهٔ اصلی</h1>
<p className="mt-1 text-sm text-gray-400">
بخشهای صفحهٔ اصلی را روشن/خاموش، جابهجا و محتوای آنها را ویرایش کنید. متنها برای دو زبان فارسی و انگلیسی جداگانه قابل تنظیماند؛ خالی بگذارید تا مقدار پیشفرض نمایش داده شود.
</p>
</div>
<button className={btn} onClick={save} disabled={saving || loading}>
{saving ? "در حال ذخیره…" : "ذخیره چیدمان"}
</button>
</div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
{msg && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">{msg}</p>}
{loading ? (
<p className="py-10 text-center text-gray-500">در حال بارگذاری</p>
) : (
<div className="space-y-2">
{sections.map((s, i) => {
const meta = metaFor(s.key);
const isOpen = open === s.key;
return (
<div key={s.key} className={card}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex flex-col">
<button className={ghost} onClick={() => move(i, -1)} disabled={i === 0} title="بالا"></button>
<button className={ghost + " mt-1"} onClick={() => move(i, 1)} disabled={i === sections.length - 1} title="پایین"></button>
</div>
<div className="flex-1">
<p className="text-sm font-medium text-white">{meta.label}</p>
<p className="text-[11px] text-gray-500">{s.enabled ? "نمایش داده می‌شود" : "مخفی"}</p>
</div>
<label className="flex items-center gap-2 text-xs text-gray-300">
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s.key)} />
فعال
</label>
{meta.fields.length > 0 && (
<button className={ghost} onClick={() => setOpen(isOpen ? null : s.key)}>
{isOpen ? "بستن" : "ویرایش محتوا"}
</button>
)}
</div>
{meta.note && <p className="px-4 pb-3 text-[11px] text-gray-500">{meta.note}</p>}
{isOpen && meta.fields.length > 0 && (
<div className="grid grid-cols-1 gap-4 border-t border-[#1e2235] p-4 sm:grid-cols-2">
{meta.fields.map((f) => (
<div key={f.key} className={f.type === "textarea" ? "sm:col-span-2" : ""}>
{f.localized ? (
<div className="grid grid-cols-2 gap-2">
<div>
<label className={lbl}>{f.label} فارسی</label>
{fieldInput(s, f, "_fa")}
</div>
<div>
<label className={lbl}>{f.label} English</label>
{fieldInput(s, f, "_en")}
</div>
</div>
) : (
<>
<label className={lbl}>{f.label}</label>
{fieldInput(s, f, "")}
</>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+7 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { import {
Accordion, Accordion,
@@ -9,17 +9,20 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import { SectionReveal } from "./SectionReveal"; import { SectionReveal } from "./SectionReveal";
export interface FAQProps { export interface FAQProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
const FAQ_IDS = ["q0","q1","q2","q3","q4","q5","q6","q7"] as const; const FAQ_IDS = ["q0","q1","q2","q3","q4","q5","q6","q7"] as const;
export function FAQ({ className }: FAQProps) { export function FAQ({ className, config }: FAQProps) {
const t = useTranslations("faq"); const t = useTranslations("faq");
const locale = useLocale();
const items = FAQ_IDS.map((id) => ({ const items = FAQ_IDS.map((id) => ({
id, id,
@@ -34,10 +37,10 @@ export function FAQ({ className }: FAQProps) {
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal> <SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"> <h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600"> <p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
{t("subtitle")} {cfgVal(config, "subtitle", locale) ?? t("subtitle")}
</p> </p>
</SectionReveal> </SectionReveal>
+23 -12
View File
@@ -2,16 +2,18 @@
import Link from "next/link"; import Link from "next/link";
import { motion, type Variants } from "framer-motion"; import { motion, type Variants } from "framer-motion";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import { HeroBackgroundBlobs } from "./HeroBackgroundBlobs"; import { HeroBackgroundBlobs } from "./HeroBackgroundBlobs";
import { HeroPreviewCards } from "./HeroPreviewCards"; import { HeroPreviewCards } from "./HeroPreviewCards";
export interface HeroProps { export interface HeroProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
const fadeUp: Variants = { const fadeUp: Variants = {
@@ -23,8 +25,15 @@ const fadeUp: Variants = {
}, },
}; };
export function Hero({ className }: HeroProps) { export function Hero({ className, config }: HeroProps) {
const t = useTranslations("hero"); const t = useTranslations("hero");
const locale = useLocale();
const title = cfgVal(config, "title", locale);
const description = cfgVal(config, "description", locale);
const ctaLabel = cfgVal(config, "ctaLabel", locale);
const ctaHref = cfgVal(config, "ctaHref", locale);
const browseLabel = cfgVal(config, "browseLabel", locale);
const browseHref = cfgVal(config, "browseHref", locale);
return ( return (
<section <section
@@ -59,20 +68,22 @@ export function Hero({ className }: HeroProps) {
variants={fadeUp} variants={fadeUp}
className="mt-6 font-heading text-4xl font-bold leading-[1.1] tracking-tight text-neutral-900 sm:mt-8 sm:text-5xl lg:text-[3.25rem]" className="mt-6 font-heading text-4xl font-bold leading-[1.1] tracking-tight text-neutral-900 sm:mt-8 sm:text-5xl lg:text-[3.25rem]"
> >
{t.rich("title", { {title
highlight: (chunks) => ( ? title
<span className="bg-gradient-to-r from-blue-600 via-violet-500 to-blue-500 bg-clip-text text-transparent"> : t.rich("title", {
{chunks} highlight: (chunks) => (
</span> <span className="bg-gradient-to-r from-blue-600 via-violet-500 to-blue-500 bg-clip-text text-transparent">
), {chunks}
})} </span>
),
})}
</motion.h1> </motion.h1>
<motion.p <motion.p
variants={fadeUp} variants={fadeUp}
className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-neutral-600 sm:text-lg" className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-neutral-600 sm:text-lg"
> >
{t("description")} {description ?? t("description")}
</motion.p> </motion.p>
<motion.div <motion.div
@@ -84,7 +95,7 @@ export function Hero({ className }: HeroProps) {
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 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"
asChild asChild
> >
<Link href="/auth?tab=sign-up">{t("cta")}</Link> <Link href={ctaHref || "/auth?tab=sign-up"}>{ctaLabel ?? t("cta")}</Link>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -92,7 +103,7 @@ export function Hero({ className }: HeroProps) {
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 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"
asChild asChild
> >
<Link href="#templates">{t("browse")}</Link> <Link href={browseHref || "#templates"}>{browseLabel ?? t("browse")}</Link>
</Button> </Button>
</motion.div> </motion.div>
</motion.div> </motion.div>
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { fetchHomeEvents } from "@/lib/home-extras";
/** Admin-managed promo banners (content-svc /v1/home-events). Renders nothing when empty.
* Per-event background/text colours are admin data → applied via inline style. */
export async function HomeEvents() {
const events = await fetchHomeEvents();
if (!events.length) return null;
return (
<section className="w-full bg-white py-8">
<div className="mx-auto max-w-7xl space-y-3 px-4 sm:px-6 lg:px-8">
{events.map((e) => (
<div
key={e.id}
className="flex flex-col items-start justify-between gap-3 rounded-2xl border border-gray-100 bg-neutral-50 p-5 shadow-sm sm:flex-row sm:items-center"
style={{
backgroundColor: e.background_color || undefined,
color: e.text_color || undefined,
}}
>
<div className="flex items-center gap-3">
{e.badge && (
<span className="shrink-0 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
{e.badge}
</span>
)}
<div>
{e.title && <p className="font-heading text-lg font-bold">{e.title}</p>}
{e.subtitle && <p className="text-sm opacity-80">{e.subtitle}</p>}
</div>
</div>
{e.button_text && e.button_url && (
<Link
href={e.button_url}
className="shrink-0 rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
{e.button_text}
</Link>
)}
</div>
))}
</div>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Image from "next/image";
import Link from "next/link";
import { fetchSlides } from "@/lib/home-extras";
/** Admin-managed hero slides (content-svc /v1/slides). Renders nothing when empty. */
export async function HomeSlides() {
const slides = await fetchSlides();
if (!slides.length) return null;
return (
<section className="w-full bg-white py-10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{slides.map((s) => {
const inner = (
<div className="group relative aspect-[16/9] overflow-hidden rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100">
{s.image && (
<Image
src={s.image}
alt={s.title ?? ""}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
)}
{s.title && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4">
<span className="font-heading text-lg font-bold text-white">{s.title}</span>
</div>
)}
</div>
);
return s.parameter ? (
<Link key={s.id} href={s.parameter} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 rounded-2xl">
{inner}
</Link>
) : (
<div key={s.id}>{inner}</div>
);
})}
</div>
</div>
</section>
);
}
+7 -4
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { LayoutTemplate, Share2, Wand2 } from "lucide-react"; import { LayoutTemplate, Share2, Wand2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import { SectionReveal } from "./SectionReveal"; import { SectionReveal } from "./SectionReveal";
import { HowItWorksConnector } from "./HowItWorksConnector"; import { HowItWorksConnector } from "./HowItWorksConnector";
@@ -11,6 +12,7 @@ import { HowItWorksStep } from "./HowItWorksStep";
export interface HowItWorksProps { export interface HowItWorksProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
const STEP_ICONS = [LayoutTemplate, Wand2, Share2]; const STEP_ICONS = [LayoutTemplate, Wand2, Share2];
@@ -20,8 +22,9 @@ const STEP_CLASSES = [
"bg-gradient-to-br from-neutral-100 to-neutral-50 border-neutral-200 text-neutral-600", "bg-gradient-to-br from-neutral-100 to-neutral-50 border-neutral-200 text-neutral-600",
]; ];
export function HowItWorks({ className }: HowItWorksProps) { export function HowItWorks({ className, config }: HowItWorksProps) {
const t = useTranslations("howItWorks"); const t = useTranslations("howItWorks");
const locale = useLocale();
const steps = [ const steps = [
{ number: 1, title: t("step1Title"), description: t("step1Desc"), icon: STEP_ICONS[0], previewClassName: STEP_CLASSES[0] }, { number: 1, title: t("step1Title"), description: t("step1Desc"), icon: STEP_ICONS[0], previewClassName: STEP_CLASSES[0] },
@@ -34,10 +37,10 @@ export function HowItWorks({ className }: HowItWorksProps) {
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal className="text-center"> <SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"> <h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-neutral-600"> <p className="mx-auto mt-4 max-w-2xl text-neutral-600">
{t("subtitle")} {cfgVal(config, "subtitle", locale) ?? t("subtitle")}
</p> </p>
</SectionReveal> </SectionReveal>
+6 -3
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { cfgVal } from "@/lib/home-layout";
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle"; import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
import { PricingCard } from "@/components/sections/PricingCard"; import { PricingCard } from "@/components/sections/PricingCard";
import { PricingCompareTable } from "@/components/sections/PricingCompareTable"; import { PricingCompareTable } from "@/components/sections/PricingCompareTable";
@@ -14,17 +15,19 @@ import { PRICING_TIERS } from "@/components/sections/pricing-data";
export interface PricingProps { export interface PricingProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
export function Pricing({ className }: PricingProps) { export function Pricing({ className, config }: PricingProps) {
const t = useTranslations("pricing"); const t = useTranslations("pricing");
const locale = useLocale();
const [billing, setBilling] = useState<BillingPeriod>("annual"); const [billing, setBilling] = useState<BillingPeriod>("annual");
return ( return (
<PricingSectionShell className={className}> <PricingSectionShell className={className}>
<SectionReveal className="text-center"> <SectionReveal className="text-center">
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"> <h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</h2> </h2>
</SectionReveal> </SectionReveal>
+6 -3
View File
@@ -2,14 +2,16 @@
import { Clapperboard, ImageIcon } from "lucide-react"; import { Clapperboard, ImageIcon } from "lucide-react";
import { motion, type Variants } from "framer-motion"; import { motion, type Variants } from "framer-motion";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import { ProductShowcaseCard } from "./ProductShowcaseCard"; import { ProductShowcaseCard } from "./ProductShowcaseCard";
export interface ProductsShowcaseProps { export interface ProductsShowcaseProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
const fadeUp: Variants = { const fadeUp: Variants = {
@@ -21,8 +23,9 @@ const fadeUp: Variants = {
}, },
}; };
export function ProductsShowcase({ className }: ProductsShowcaseProps) { export function ProductsShowcase({ className, config }: ProductsShowcaseProps) {
const t = useTranslations("products"); const t = useTranslations("products");
const locale = useLocale();
const products = [ const products = [
{ {
@@ -78,7 +81,7 @@ export function ProductsShowcase({ className }: ProductsShowcaseProps) {
variants={fadeUp} variants={fadeUp}
className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl" className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"
> >
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</motion.h2> </motion.h2>
<motion.div <motion.div
+6 -3
View File
@@ -5,9 +5,10 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import type { AdminProject } from "@/lib/admin-api"; import type { AdminProject } from "@/lib/admin-api";
import { SectionReveal } from "./SectionReveal"; import { SectionReveal } from "./SectionReveal";
@@ -51,11 +52,13 @@ export interface TemplateGalleryProps {
className?: string; className?: string;
/** Live projects from the admin API. Falls back to hardcoded list when empty. */ /** Live projects from the admin API. Falls back to hardcoded list when empty. */
adminItems?: AdminProject[]; adminItems?: AdminProject[];
config?: Record<string, string>;
} }
export function TemplateGallery({ className, adminItems }: TemplateGalleryProps) { export function TemplateGallery({ className, adminItems, config }: TemplateGalleryProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations("templates"); const t = useTranslations("templates");
const locale = useLocale();
const [activeTab, setActiveTab] = useState<FilterTab>("All"); const [activeTab, setActiveTab] = useState<FilterTab>("All");
// Real admin templates only — no hardcoded demo fallback. // Real admin templates only — no hardcoded demo fallback.
@@ -86,7 +89,7 @@ export function TemplateGallery({ className, adminItems }: TemplateGalleryProps)
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal> <SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"> <h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</h2> </h2>
</SectionReveal> </SectionReveal>
+6 -3
View File
@@ -1,20 +1,23 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cfgVal } from "@/lib/home-layout";
import { SectionReveal } from "./SectionReveal"; import { SectionReveal } from "./SectionReveal";
import { TestimonialCard } from "./TestimonialCard"; import { TestimonialCard } from "./TestimonialCard";
export interface TestimonialsProps { export interface TestimonialsProps {
className?: string; className?: string;
config?: Record<string, string>;
} }
const TESTIMONIAL_INDICES = [0, 1, 2, 3, 4, 5] as const; const TESTIMONIAL_INDICES = [0, 1, 2, 3, 4, 5] as const;
export function Testimonials({ className }: TestimonialsProps) { export function Testimonials({ className, config }: TestimonialsProps) {
const t = useTranslations("testimonials"); const t = useTranslations("testimonials");
const locale = useLocale();
const testimonials = TESTIMONIAL_INDICES.map((i) => ({ const testimonials = TESTIMONIAL_INDICES.map((i) => ({
id: `item${i}`, id: `item${i}`,
@@ -30,7 +33,7 @@ export function Testimonials({ className }: TestimonialsProps) {
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal> <SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl"> <h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{t("heading")} {cfgVal(config, "heading", locale) ?? t("heading")}
</h2> </h2>
</SectionReveal> </SectionReveal>
+36
View File
@@ -0,0 +1,36 @@
/** Public reads for the homepage Slides carousel + Home Events banner sections. */
import { gatewayUrl } from "@/lib/api/gateway";
export interface HomeSlide {
id: string;
title?: string | null;
image?: string | null;
parameter?: string | null; // link target
keyword?: string | null;
}
export interface HomeEvent {
id: string;
title?: string | null;
subtitle?: string | null;
badge?: string | null;
button_text?: string | null;
button_url?: string | null;
background_color?: string | null;
text_color?: string | null;
image?: string | null;
}
async function getList<T>(path: string): Promise<T[]> {
try {
const r = await fetch(gatewayUrl(path), { next: { revalidate: 30 }, headers: { Accept: "application/json" } });
if (!r.ok) return [];
const d = await r.json();
return Array.isArray(d) ? (d as T[]) : ((d?.items ?? []) as T[]);
} catch {
return [];
}
}
export const fetchSlides = () => getList<HomeSlide>("/v1/slides/");
export const fetchHomeEvents = () => getList<HomeEvent>("/v1/home-events/");
+94
View File
@@ -0,0 +1,94 @@
/**
* Homepage layout: which sections appear, in what order, toggled on/off, plus
* per-section content overrides. Stored as a single Website Setting (key
* `home_layout`, jsonb value) — read publicly via the gateway, edited in admin.
*
* The CODE is the source of truth for which section *types* exist (SECTION_CATALOG);
* the saved layout only overrides enabled / sort / config. New section types added
* in code therefore appear automatically (appended, default-enabled per catalog).
*/
import { gatewayUrl } from "@/lib/api/gateway";
export const HOME_LAYOUT_KEY = "home_layout";
export interface HomeSection {
key: string;
enabled: boolean;
sort: number;
/** Flat overrides; localized fields use `<field>_fa` / `<field>_en` keys. */
config?: Record<string, string>;
}
export interface HomeLayout {
sections: HomeSection[];
}
/** Canonical catalog: declaration order = default order; defaultEnabled = initial on/off. */
export const SECTION_CATALOG: { key: string; defaultEnabled: boolean }[] = [
{ key: "hero", defaultEnabled: true },
{ key: "slides", defaultEnabled: false },
{ key: "products", defaultEnabled: true },
{ key: "templates", defaultEnabled: true },
{ key: "howItWorks", defaultEnabled: true },
{ key: "pricing", defaultEnabled: true },
{ key: "testimonials", defaultEnabled: true },
{ key: "faq", defaultEnabled: true },
{ key: "events", defaultEnabled: false },
];
export function defaultSections(): HomeSection[] {
return SECTION_CATALOG.map((s, i) => ({ key: s.key, enabled: s.defaultEnabled, sort: i, config: {} }));
}
/** Merge a saved layout with the catalog: saved enabled/sort/config win; catalog
* sections missing from the saved layout are appended after it. */
export function mergeLayout(saved: HomeLayout | null | undefined): HomeSection[] {
const def = defaultSections();
if (!saved?.sections?.length) return def;
const byKey = new Map(saved.sections.map((s) => [s.key, s]));
let maxSort = Math.max(0, ...saved.sections.map((s) => Number(s.sort) || 0));
const merged: HomeSection[] = def.map((d) => {
const s = byKey.get(d.key);
return s
? { key: d.key, enabled: s.enabled ?? d.enabled, sort: Number(s.sort) ?? d.sort, config: s.config ?? {} }
: { key: d.key, enabled: d.enabled, sort: ++maxSort, config: {} };
});
return merged.sort((a, b) => a.sort - b.sort);
}
/** Read a localized config override: `<field>_<locale>` → `<field>` → undefined. */
export function cfgVal(
config: Record<string, string> | undefined,
field: string,
locale: string,
): string | undefined {
if (!config) return undefined;
const v = config[`${field}_${locale}`] ?? config[field];
return v && String(v).trim() !== "" ? v : undefined;
}
/** Server-side: fetch the ordered, catalog-merged homepage sections. Falls back to
* defaults when the gateway is unset/unreachable or the setting is absent. */
export async function fetchHomeLayout(): Promise<HomeSection[]> {
try {
const res = await fetch(gatewayUrl("/v1/settings/"), {
next: { revalidate: 30 },
headers: { Accept: "application/json" },
});
if (!res.ok) return mergeLayout(null);
const rows = (await res.json()) as Array<{ key: string; value: string }>;
const row = Array.isArray(rows) ? rows.find((r) => r.key === HOME_LAYOUT_KEY) : null;
if (!row?.value) return mergeLayout(null);
let parsed: HomeLayout | null = null;
try {
const v = typeof row.value === "string" ? JSON.parse(row.value) : row.value;
parsed = v && typeof v === "object" && Array.isArray(v.sections) ? (v as HomeLayout) : null;
} catch {
parsed = null;
}
return mergeLayout(parsed);
} catch {
return mergeLayout(null);
}
}