Connect storefront to backend catalog, search, and home slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 2f857f1a90
commit 0074ab6629
106 changed files with 5920 additions and 81 deletions
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import AboutSection from "@/components/home/AboutSection";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import { ABOUT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
export const metadata: Metadata = {
title: "درباره ما | مانا کالکشن",
description: ABOUT_PAGE.lead,
};
export default async function AboutPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={ABOUT_PAGE.titleFa}
titleEn={ABOUT_PAGE.titleEn}
heroImage={home.heroSlides[0]?.image || ABOUT_PAGE.heroImage}
breadcrumbs={ABOUT_PAGE.breadcrumbs}
/>
</div>
<AboutSection
titleFa={home.about.titleFa}
titleEn={home.about.titleEn}
lead={home.about.lead}
body={home.about.body}
image={home.about.image}
imageSecondary={home.about.imageSecondary}
/>
<section className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
{ABOUT_PAGE.storyTitleFa}
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{ABOUT_PAGE.storyTitleEn}
</p>
<p className="mt-6 text-sm leading-8 text-ink/80">
{ABOUT_PAGE.story}
</p>
<Link
href="/contact"
className="mt-8 inline-flex border border-ink px-5 py-2.5 text-sm transition-colors hover:bg-ink hover:text-white"
>
تماس با ما
</Link>
</div>
<ul className="space-y-6">
{ABOUT_PAGE.values.map((value) => (
<li
key={value.titleEn}
className="border border-line bg-[#f7f7f5] p-5"
>
<p className="text-base text-ink">{value.titleFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{value.titleEn}
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
{value.body}
</p>
</li>
))}
</ul>
</div>
<div className="relative mt-12 aspect-[21/9] overflow-hidden bg-[#f3f3f1]">
<Image
src={home.about.imageSecondary || ABOUT_PAGE.imageSecondary}
alt=""
fill
sizes="100vw"
className="object-cover"
/>
</div>
</section>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+164
View File
@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import { getCategoryPage } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
type ApiCategoryNode,
} from "@/lib/categories-api";
import { categoryPath, categorySlug } from "@/lib/category-path";
type PageProps = {
params: Promise<{ path: string[] }>;
};
function findCategory(
nodes: ApiCategoryNode[],
id: string,
): ApiCategoryNode | null {
for (const node of nodes) {
if (String(node.id) === id) return node;
const child = findCategory(node.children ?? [], id);
if (child) return child;
}
return null;
}
async function resolveCategory(id: string) {
const apiCategories = await fetchPublicCategories();
const apiCat = findCategory(apiCategories, id);
if (apiCat) {
return {
apiCategories,
apiCat,
staticCat: null as ReturnType<typeof getCategoryPage>,
id: apiCat.id,
nameFa: apiCat.nameFa,
nameEn: apiCat.nameEn || apiCat.nameFa,
imageUrl: apiCat.imageUrl,
description: `مجموعه‌ای از محصولات ${apiCat.nameFa} در مانا کالکشن.`,
};
}
const staticCat = getCategoryPage(id);
if (staticCat) {
return {
apiCategories,
apiCat: null,
staticCat,
id: staticCat.id,
nameFa: staticCat.nameFa,
nameEn: staticCat.nameEn,
imageUrl: null as string | null,
description: staticCat.description,
};
}
return null;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { path } = await params;
const id = path?.[0];
if (!id) return { title: "دسته | مانا کالکشن" };
const resolved = await resolveCategory(id);
if (!resolved) return { title: "دسته | مانا کالکشن" };
return {
title: `${resolved.nameFa} | مانا کالکشن`,
description: resolved.description,
};
}
export default async function CategoryPage({ params }: PageProps) {
const { path } = await params;
if (!path?.length || path.length > 2) notFound();
const [id, slug] = path;
const resolved = await resolveCategory(id);
if (!resolved) notFound();
const expectedSlug = categorySlug({
id: resolved.id,
nameEn: resolved.nameEn,
nameFa: resolved.nameFa,
});
const canonical = categoryPath({
id: resolved.id,
nameEn: resolved.nameEn,
nameFa: resolved.nameFa,
});
// Legacy `/categories/:id` or wrong slug → canonical `/categories/:id/:slug`
if (path.length === 1 || slug !== expectedSlug) {
redirect(canonical);
}
const [slotItems, products] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts(
resolved.apiCat ? { categoryId: resolved.id } : undefined,
),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
resolved.apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={resolved.nameFa}
titleEn={resolved.nameEn}
heroImage={
home.heroSlides[0]?.image ||
resolved.imageUrl ||
resolved.staticCat?.heroImage
}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: resolved.nameFa },
]}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 pt-8 sm:px-6 lg:px-8">
<p className="max-w-2xl text-sm leading-7 text-ink/75">
{resolved.description}
</p>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[48px]">
<ProductFilters />
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
</div>
</div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
} from "@/lib/categories-api";
import { categoryPath } from "@/lib/category-path";
export const metadata: Metadata = {
title: "دسته‌بندی‌ها | مانا کالکشن",
description: "مرور دسته‌بندی‌های مانا کالکشن",
};
export default async function CategoriesIndexPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const list = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa="دسته‌بندی‌ها"
titleEn="Categories"
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "دسته‌بندی‌ها" },
]}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3 lg:gap-6">
{list.map((cat) => (
<li key={cat.id}>
<Link
href={cat.href || categoryPath(cat)}
className="group block overflow-hidden rounded-[5px] border border-line bg-white transition-colors hover:border-ink"
>
<div className="relative aspect-[4/5] overflow-hidden bg-[#f6f6f4]">
<Image
src={cat.image}
alt={cat.nameFa}
fill
sizes="(max-width: 768px) 50vw, 360px"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
<div className="flex items-end justify-between gap-3 px-4 py-3">
<div>
<p className="text-base text-ink">{cat.nameFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{cat.nameEn}
</p>
</div>
<span className="text-xs text-muted transition-colors group-hover:text-ink">
مشاهده
</span>
</div>
</Link>
</li>
))}
</ul>
</div>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ContactForm from "@/components/contact/ContactForm";
import { CONTACT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
export const metadata: Metadata = {
title: "تماس با ما | مانا کالکشن",
description: CONTACT_PAGE.lead,
};
export default async function ContactPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={CONTACT_PAGE.titleFa}
titleEn={CONTACT_PAGE.titleEn}
heroImage={home.heroSlides[0]?.image || CONTACT_PAGE.heroImage}
breadcrumbs={CONTACT_PAGE.breadcrumbs}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:gap-14">
<aside className="space-y-6 border border-line bg-[#f7f7f5] p-6 sm:p-8">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
اطلاعات تماس
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Get in touch
</p>
</div>
<dl className="space-y-4 text-sm leading-7">
<div>
<dt className="text-muted">آدرس</dt>
<dd className="text-ink">{CONTACT_PAGE.address}</dd>
</div>
<div>
<dt className="text-muted">تلفن فروشگاه</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.phoneStore}
</dd>
</div>
<div>
<dt className="text-muted">تلفن همراه</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.phoneMobile}
</dd>
</div>
<div>
<dt className="text-muted">ایمیل</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.email}
</dd>
</div>
<div>
<dt className="text-muted">ساعات پاسخگویی</dt>
<dd className="text-ink">{CONTACT_PAGE.hours}</dd>
</div>
</dl>
</aside>
<div>
<h2 className="font-doran text-2xl font-light text-ink">
فرم تماس
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Send a message
</p>
<div className="mt-6">
<ContactForm />
</div>
</div>
</div>
</div>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+80 -2
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
:root {
--background: #fbfbfa;
--background: #ffffff;
--foreground: #0a0a0a;
--surface: #ffffff;
--muted: #6b6b6b;
@@ -16,13 +16,17 @@
--color-muted: var(--muted);
--color-line: var(--line);
--color-ink: var(--ink);
--font-sans: var(--font-doran);
--font-sans: var(--font-iranyekan);
--font-display: var(--font-cailyne);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-iranyekan), Tahoma, sans-serif;
}
.font-doran {
font-family: var(--font-doran), Tahoma, sans-serif;
}
@@ -35,6 +39,10 @@ body {
font-family: var(--font-montserrat), system-ui, sans-serif;
}
.home-page {
overflow-x: clip;
}
@keyframes coming-soon-rise {
from {
opacity: 0;
@@ -157,6 +165,76 @@ body {
}
}
@keyframes hero-ken-burns {
from {
transform: scale(1);
}
to {
transform: scale(1.06);
}
}
.hero-ken-burns {
animation: hero-ken-burns 10s linear forwards;
will-change: transform;
transform-origin: center center;
}
.hero-ken-burns-paused {
animation-play-state: paused;
}
.seq-fade-item {
transition:
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
@media (prefers-reduced-motion: reduce) {
.seq-fade-item {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
@keyframes hero-copy-rise {
from {
opacity: 0;
transform: translateY(18px);
filter: blur(6px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.hero-copy-item {
opacity: 0;
animation: hero-copy-rise 0.75s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.hero-copy-item-2 {
animation-delay: 0.12s;
}
.hero-copy-item-3 {
animation-delay: 0.24s;
}
@media (prefers-reduced-motion: reduce) {
.hero-ken-burns,
.hero-copy-item {
animation: none !important;
opacity: 1 !important;
filter: none !important;
transform: none !important;
}
}
.mana-canvas {
background:
radial-gradient(ellipse 80% 60% at 20% 20%, rgba(0, 0, 0, 0.02), transparent 55%),
+46 -3
View File
@@ -3,6 +3,48 @@ import { Montserrat } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
const iranYekan = localFont({
src: [
{
path: "../fonts/iranyekan/IRANYekanThinFaNum.ttf",
weight: "100",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanLightFaNum.ttf",
weight: "300",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanRegularFaNum.ttf",
weight: "400",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanMediumFaNum.ttf",
weight: "500",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBoldFaNum.ttf",
weight: "700",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanExtraBoldFaNum.ttf",
weight: "800",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBlackFaNum.ttf",
weight: "900",
style: "normal",
},
],
variable: "--font-iranyekan",
display: "swap",
});
const doran = localFont({
src: [
{
@@ -50,8 +92,9 @@ const montserrat = Montserrat({
});
export const metadata: Metadata = {
title: "به‌زودی | مانا کالکشن",
description: "فروشگاه آنلاین مانا کالکشن به‌زودی در دسترس خواهد بود.",
title: "مانا کالکشن | Mana Collection",
description:
"فروشگاه آنلاین مانا کالکشن — پوشاک زنانه، کالکشن پاییزه و انتخاب‌های خاص برای بانویی که خودش را انتخاب می‌کند.",
};
export default function RootLayout({
@@ -63,7 +106,7 @@ export default function RootLayout({
<html
lang="fa"
dir="rtl"
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
className={`${iranYekan.variable} ${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-foreground">{children}</body>
</html>
+102 -74
View File
@@ -1,81 +1,109 @@
import Image from "next/image";
import ManaLogo from "@/components/ui/ManaLogo";
import SiteHeader from "@/components/home/SiteHeader";
import HeroSlider from "@/components/home/HeroSlider";
import CategoryStrip from "@/components/home/CategoryStrip";
import ProductRow from "@/components/home/ProductRow";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import FeaturesBar from "@/components/home/FeaturesBar";
import BlackCollection from "@/components/home/BlackCollection";
import AboutSection from "@/components/home/AboutSection";
import SiteFooter from "@/components/home/SiteFooter";
import { mapHomeSlots } from "@/lib/map-home-slots";
import {
fetchPublicWebsiteSlots,
itemsForSlot,
} from "@/lib/website-slots";
import { getStorefrontCatalog } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
} from "@/lib/categories-api";
const STORE_LOGIN =
process.env.NEXT_PUBLIC_STORE_URL?.replace(/\/$/, "") ||
"http://store.mana-collection.com:3001";
export default async function HomePage() {
const [slotItems, catalog, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontCatalog(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const saleProducts = catalog.promo1;
const discountProducts = catalog.promo2;
const autumnProducts = catalog.promo3;
const stripCategories = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({
image: t.image,
nameFa: t.nameFa,
href: t.href,
})),
);
// Prefer Website Images → category-strip slots over catalog categories
const hasWebsiteCategoryStrip =
itemsForSlot(slotItems, "category-strip").length > 0;
const categories = hasWebsiteCategoryStrip
? home.categories
: stripCategories.length
? stripCategories
: home.categories;
export default function HomePage() {
return (
<main className="coming-soon relative min-h-dvh overflow-hidden bg-[#f7f7f5] text-ink">
{/* dir=ltr keeps content on visual left, image on visual right */}
<div dir="ltr" className="grid min-h-dvh grid-cols-1 lg:grid-cols-2">
{/* Content — left */}
<section className="relative order-2 flex min-h-[52dvh] flex-col lg:order-1 lg:min-h-dvh">
<div className="mana-canvas absolute inset-0" aria-hidden />
<div
dir="rtl"
className="relative z-10 flex flex-1 flex-col px-7 py-8 sm:px-12 sm:py-10 lg:px-16 lg:py-14"
>
<header className="coming-soon-seq coming-soon-seq-1 flex shrink-0 justify-center">
<ManaLogo
href=""
width={400}
priority
showTagline
variant="white"
blackBg
className="w-[400px] max-w-[min(400px,100%)]"
/>
</header>
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center sm:py-16">
<div className="max-w-md">
<p
dir="ltr"
className="coming-soon-seq coming-soon-seq-2 mb-4 font-display text-sm tracking-[0.2em] text-muted sm:text-base"
>
Coming soon
</p>
<h1 className="coming-soon-seq coming-soon-seq-3 mb-5 text-4xl leading-tight text-ink sm:text-5xl">
به‌زودی
</h1>
<p className="coming-soon-seq coming-soon-seq-4 text-sm leading-8 text-muted sm:text-base sm:leading-8">
در این آدرس میزبان شما خواهیم بود.
</p>
</div>
</div>
<footer className="coming-soon-seq coming-soon-seq-5 shrink-0 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="text-xs leading-6 text-muted">
حساب دارید؟ وارد شوید.
</p>
<a
href={`${STORE_LOGIN}/login`}
className="coming-soon-login inline-flex items-center justify-center border border-ink bg-ink px-7 py-2.5 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
ورود
</a>
</div>
</footer>
</div>
</section>
{/* Image — right */}
<aside className="coming-soon-seq-image relative order-1 min-h-[42dvh] overflow-hidden lg:order-2 lg:min-h-dvh">
<Image
src="/images/image1.jpg"
alt="مانا کالکشن"
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="coming-soon-image object-cover object-center"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-black/10" />
</aside>
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<HeroSlider slides={home.heroSlides} />
</div>
<CategoryStrip categories={categories} />
<div className="flex flex-col gap-10 lg:gap-14">
<ProductRow
products={saleProducts}
promo="sale"
stagger
promoSlot={home.promoSale}
/>
<div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
</div>
<ProductRow
products={discountProducts}
promo="discount"
stagger
promoSlot={home.promoDiscount}
/>
<FeaturesBar />
<BlackCollection items={home.blackItems} />
<ProductRow
products={autumnProducts}
promo="autumn"
stagger
promoSlot={home.promoAutumn}
/>
</div>
<AboutSection
titleFa={home.about.titleFa}
titleEn={home.about.titleEn}
lead={home.about.lead}
body={home.about.body}
image={home.about.image}
imageSecondary={home.about.imageSecondary}
/>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import ProductDetailHero from "@/components/product-detail/ProductDetailHero";
import ProductGallery from "@/components/product-detail/ProductGallery";
import ProductBuyBox from "@/components/product-detail/ProductBuyBox";
import ProductTabs from "@/components/product-detail/ProductTabs";
import SimilarProducts from "@/components/product-detail/SimilarProducts";
import { getProductDetail } from "@/data/product-detail";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicProduct,
getStorefrontProducts,
mapApiProductToDetail,
} from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
import { productPath, productSlug } from "@/lib/product-path";
type PageProps = {
params: Promise<{ path: string[] }>;
};
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { path } = await params;
const id = path?.[0];
if (!id) return { title: "محصول | مانا کالکشن" };
const api = await fetchPublicProduct(id);
if (api) {
return {
title: `${api.nameFa} | مانا کالکشن`,
description: api.summary || api.description || api.nameFa,
};
}
const fallback = getProductDetail(id);
if (!fallback) return { title: "محصول | مانا کالکشن" };
return {
title: `${fallback.nameFa} | مانا کالکشن`,
description: fallback.summary,
};
}
export default async function ProductDetailPage({ params }: PageProps) {
const { path } = await params;
if (!path?.length || path.length > 2) notFound();
const [id, slug] = path;
const [slotItems, apiProduct, similar, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicProduct(id),
getStorefrontProducts(),
fetchPublicCategories(),
]);
const product = apiProduct
? mapApiProductToDetail(apiProduct)
: getProductDetail(id);
if (!product) notFound();
const expectedSlug = productSlug({
id: product.id,
nameEn: product.nameEn,
nameFa: product.nameFa,
});
const canonical = productPath({
id: product.id,
nameEn: product.nameEn,
nameFa: product.nameFa,
});
// Legacy `/products/:id` or wrong slug → canonical `/products/:id/:slug`
if (path.length === 1 || slug !== expectedSlug) {
redirect(canonical);
}
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
const similarCards = similar
.filter((p) => p.id !== product.id)
.slice(0, 8);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductDetailHero
heroImage={home.heroSlides[0]?.image || undefined}
breadcrumbs={product.breadcrumbs}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
<ProductGallery images={product.gallery} alt={product.nameFa} />
<ProductBuyBox product={product} />
</div>
<div className="mt-12 space-y-14 lg:mt-16 lg:space-y-16">
<ProductTabs product={product} />
<SimilarProducts
products={
similarCards.length
? similarCards
: similar.slice(0, 8)
}
/>
</div>
</div>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
type PageProps = {
searchParams: Promise<{ q?: string; cat?: string }>;
};
export default async function ProductsPage({ searchParams }: PageProps) {
const params = await searchParams;
const [slotItems, products, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts({
name: params.q,
categoryId: params.cat,
}),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={params.q ? `جستجو: ${params.q}` : LIST_PAGE_META.titleFa}
titleEn={params.q ? "Search results" : LIST_PAGE_META.titleEn}
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
breadcrumbs={
params.q
? [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "جستجو" },
]
: LIST_PAGE_META.breadcrumbs
}
/>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[60px]">
<ProductFilters />
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
</div>
</div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}