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:
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { HomeSectionsManager } from "@/components/admin/HomeSectionsManager";
|
||||
|
||||
export default function Page() {
|
||||
return <HomeSectionsManager />;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export default async function AdminLayout({
|
||||
{
|
||||
title: "محتوا",
|
||||
items: [
|
||||
{ href: "/admin/home", label: t("homePage") },
|
||||
{ href: "/admin/categories", label: t("categories") },
|
||||
{ href: "/admin/templates", label: t("templates") },
|
||||
{ href: "/admin/projects", label: t("projects") },
|
||||
|
||||
+45
-13
@@ -1,15 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { FAQ } from "@/components/sections/FAQ";
|
||||
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 { Pricing } from "@/components/sections/Pricing";
|
||||
import { ProductsShowcase } from "@/components/sections/ProductsShowcase";
|
||||
import { TemplateGallery } from "@/components/sections/TemplateGallery";
|
||||
import { FAQ } from "@/components/sections/FAQ";
|
||||
import { Testimonials } from "@/components/sections/Testimonials";
|
||||
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> {
|
||||
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() {
|
||||
// Fetch up to 8 published projects from the admin service.
|
||||
// Returns an empty array when ADMIN_API_URL is not set or the service
|
||||
// is unreachable — TemplateGallery falls back to hardcoded data.
|
||||
const { items: adminProjects } = await fetchProjects({ pageSize: 8 });
|
||||
// Layout (which sections, order, on/off, content overrides) is admin-managed via
|
||||
// the `home_layout` setting; falls back to sensible defaults when unset.
|
||||
const [sections, projects] = await Promise.all([
|
||||
fetchHomeLayout(),
|
||||
fetchProjects({ pageSize: 8 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Hero />
|
||||
<ProductsShowcase />
|
||||
<TemplateGallery adminItems={adminProjects} />
|
||||
<HowItWorks />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
<FAQ />
|
||||
{sections
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => (
|
||||
<div key={s.key}>{renderSection(s, projects.items)}</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
@@ -9,17 +9,20 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
|
||||
export interface FAQProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
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 locale = useLocale();
|
||||
|
||||
const items = FAQ_IDS.map((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">
|
||||
<SectionReveal>
|
||||
<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>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
|
||||
{t("subtitle")}
|
||||
{cfgVal(config, "subtitle", locale) ?? t("subtitle")}
|
||||
</p>
|
||||
</SectionReveal>
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
|
||||
import { HeroBackgroundBlobs } from "./HeroBackgroundBlobs";
|
||||
import { HeroPreviewCards } from "./HeroPreviewCards";
|
||||
|
||||
export interface HeroProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
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 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 (
|
||||
<section
|
||||
@@ -59,20 +68,22 @@ export function Hero({ className }: HeroProps) {
|
||||
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]"
|
||||
>
|
||||
{t.rich("title", {
|
||||
highlight: (chunks) => (
|
||||
<span className="bg-gradient-to-r from-blue-600 via-violet-500 to-blue-500 bg-clip-text text-transparent">
|
||||
{chunks}
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
{title
|
||||
? title
|
||||
: t.rich("title", {
|
||||
highlight: (chunks) => (
|
||||
<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.p
|
||||
variants={fadeUp}
|
||||
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.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"
|
||||
asChild
|
||||
>
|
||||
<Link href="/auth?tab=sign-up">{t("cta")}</Link>
|
||||
<Link href={ctaHref || "/auth?tab=sign-up"}>{ctaLabel ?? t("cta")}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
asChild
|
||||
>
|
||||
<Link href="#templates">{t("browse")}</Link>
|
||||
<Link href={browseHref || "#templates"}>{browseLabel ?? t("browse")}</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { LayoutTemplate, Share2, Wand2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
import { HowItWorksConnector } from "./HowItWorksConnector";
|
||||
@@ -11,6 +12,7 @@ import { HowItWorksStep } from "./HowItWorksStep";
|
||||
|
||||
export interface HowItWorksProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
export function HowItWorks({ className }: HowItWorksProps) {
|
||||
export function HowItWorks({ className, config }: HowItWorksProps) {
|
||||
const t = useTranslations("howItWorks");
|
||||
const locale = useLocale();
|
||||
|
||||
const steps = [
|
||||
{ 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">
|
||||
<SectionReveal className="text-center">
|
||||
<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>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
|
||||
{t("subtitle")}
|
||||
{cfgVal(config, "subtitle", locale) ?? t("subtitle")}
|
||||
</p>
|
||||
</SectionReveal>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
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 { PricingCard } from "@/components/sections/PricingCard";
|
||||
import { PricingCompareTable } from "@/components/sections/PricingCompareTable";
|
||||
@@ -14,17 +15,19 @@ import { PRICING_TIERS } from "@/components/sections/pricing-data";
|
||||
|
||||
export interface PricingProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function Pricing({ className }: PricingProps) {
|
||||
export function Pricing({ className, config }: PricingProps) {
|
||||
const t = useTranslations("pricing");
|
||||
const locale = useLocale();
|
||||
const [billing, setBilling] = useState<BillingPeriod>("annual");
|
||||
|
||||
return (
|
||||
<PricingSectionShell className={className}>
|
||||
<SectionReveal className="text-center">
|
||||
<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>
|
||||
</SectionReveal>
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import { Clapperboard, ImageIcon } from "lucide-react";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
|
||||
import { ProductShowcaseCard } from "./ProductShowcaseCard";
|
||||
|
||||
export interface ProductsShowcaseProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
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 locale = useLocale();
|
||||
|
||||
const products = [
|
||||
{
|
||||
@@ -78,7 +81,7 @@ export function ProductsShowcase({ className }: ProductsShowcaseProps) {
|
||||
variants={fadeUp}
|
||||
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.div
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
import type { AdminProject } from "@/lib/admin-api";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
@@ -51,11 +52,13 @@ export interface TemplateGalleryProps {
|
||||
className?: string;
|
||||
/** Live projects from the admin API. Falls back to hardcoded list when empty. */
|
||||
adminItems?: AdminProject[];
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function TemplateGallery({ className, adminItems }: TemplateGalleryProps) {
|
||||
export function TemplateGallery({ className, adminItems, config }: TemplateGalleryProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("templates");
|
||||
const locale = useLocale();
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("All");
|
||||
|
||||
// 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">
|
||||
<SectionReveal>
|
||||
<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>
|
||||
</SectionReveal>
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
import { TestimonialCard } from "./TestimonialCard";
|
||||
|
||||
export interface TestimonialsProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
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 locale = useLocale();
|
||||
|
||||
const testimonials = TESTIMONIAL_INDICES.map((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">
|
||||
<SectionReveal>
|
||||
<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>
|
||||
</SectionReveal>
|
||||
|
||||
|
||||
@@ -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/");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user