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>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { FormEvent, useState } from "react";
import { CONTACT_PAGE } from "@/data/pages";
export default function ContactForm() {
const [sent, setSent] = useState(false);
const [saving, setSaving] = useState(false);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
window.setTimeout(() => {
setSaving(false);
setSent(true);
}, 500);
}
if (sent) {
return (
<div className="border border-line bg-[#f7f7f5] px-5 py-8 text-center">
<p className="font-doran text-xl text-ink">پیام شما دریافت شد</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Message received
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
به‌زودی با شما تماس می‌گیریم.
</p>
<button
type="button"
className="mt-5 border border-ink px-4 py-2 text-sm text-ink hover:bg-ink hover:text-white"
onClick={() => setSent(false)}
>
ارسال پیام دیگر
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm leading-7 text-ink/75">{CONTACT_PAGE.lead}</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs text-muted">نام</span>
<input
required
name="name"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="نام شما"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">شماره تماس</span>
<input
required
name="phone"
dir="ltr"
inputMode="tel"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="09xxxxxxxxx"
/>
</label>
</div>
<label className="block space-y-1.5">
<span className="text-xs text-muted">ایمیل (اختیاری)</span>
<input
name="email"
type="email"
dir="ltr"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="you@email.com"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">موضوع</span>
<input
required
name="subject"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="پیگیری سفارش، سوال سایز، همکاری..."
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">پیام</span>
<textarea
required
name="message"
rows={5}
className="w-full resize-y border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="متن پیام خود را بنویسید..."
/>
</label>
<button
type="submit"
disabled={saving}
className="group relative inline-flex h-12 w-full items-center justify-center overflow-hidden rounded-[5px] border border-ink bg-ink text-white sm:w-auto sm:min-w-[12rem]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-white transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<span className="relative z-10 text-sm transition-colors duration-[400ms] group-hover:text-ink">
{saving ? "..." : "ارسال پیام"}
</span>
</button>
</form>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Image from "next/image";
import { ABOUT } from "@/data/home";
type AboutSectionProps = {
titleFa?: string;
titleEn?: string;
lead?: string;
body?: string;
image?: string;
imageSecondary?: string;
};
export default function AboutSection({
titleFa = ABOUT.titleFa,
titleEn = ABOUT.titleEn,
lead = ABOUT.lead,
body = ABOUT.body,
image = ABOUT.image,
imageSecondary = ABOUT.imageSecondary,
}: AboutSectionProps) {
return (
<section id="about" dir="rtl" className="scroll-mt-24 bg-white py-16 lg:py-24">
<div className="mx-auto grid max-w-[1200px] items-center gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:gap-16 lg:px-8">
<div className="relative mx-auto w-full max-w-[520px] lg:mx-0">
<div
aria-hidden
className="absolute -inset-x-4 top-8 bottom-0 -z-0 rounded-[5px] border border-ink/15 bg-[#f3f3f3] sm:-inset-x-6 lg:inset-x-0 lg:-start-8 lg:end-12 lg:top-12"
style={{
backgroundImage:
"radial-gradient(circle, rgba(10,10,10,0.14) 1px, transparent 1.2px)",
backgroundSize: "14px 14px",
}}
/>
<div className="relative z-10 aspect-[4/5] w-[78%] overflow-hidden rounded-[5px] bg-[#ebe6df]">
<Image
src={image}
alt={titleFa}
fill
sizes="(max-width: 1024px) 70vw, 380px"
className="object-cover object-[center_18%]"
/>
</div>
<div className="absolute bottom-0 end-0 z-20 aspect-[3/4] w-[48%] overflow-hidden rounded-[5px] border border-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
<Image
src={imageSecondary}
alt=""
fill
sizes="(max-width: 1024px) 40vw, 240px"
className="object-cover object-top"
/>
</div>
</div>
<div className="relative text-right">
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute -top-4 start-0 font-doran text-[clamp(2rem,5vw,3.5rem)] font-normal tracking-[0.08em] text-ink/10"
>
{titleEn}
</p>
<h2 className="font-doran relative text-[clamp(1.75rem,3vw,2.5rem)] font-light text-ink">
{titleFa}
</h2>
<p className="font-doran mt-4 text-base font-medium text-ink sm:text-lg">
{lead}
</p>
<p className="mt-4 text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
{body}
</p>
</div>
</div>
</section>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { BLACK_ITEMS } from "@/data/home";
export type BlackItemData = {
id: string;
image: string;
alt: string;
nameFa: string;
nameEn: string;
href: string;
};
type BlackCollectionProps = {
items?: BlackItemData[];
};
export default function BlackCollection({ items }: BlackCollectionProps) {
const list: BlackItemData[] =
items?.length
? items
: BLACK_ITEMS.map((item) => ({
id: item.id,
image: item.image,
alt: item.alt,
nameFa: item.nameFa,
nameEn: item.nameEn,
href: `/products?color=black&item=${item.id}`,
}));
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section
ref={ref}
className="relative mx-auto w-full max-w-[1710px] px-4 py-10 sm:px-6 lg:px-[120px] lg:py-14"
>
<div
className="seq-fade-item relative mb-8 flex items-center justify-center text-center lg:mb-10"
style={{
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
}}
>
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center font-doran text-[clamp(2.5rem,8vw,6.25rem)] font-normal leading-none tracking-[0.1em] text-ink/10"
>
i Like it Black
</p>
<h2 className="font-doran relative translate-y-[0.12em] text-[clamp(1.75rem,4vw,3.125rem)] font-light leading-none text-ink">
من عاشق رنگ <span className="font-medium">مشکی</span> هستم
</h2>
</div>
<ul className="grid grid-cols-2 gap-[14px] lg:grid-cols-4 lg:gap-[22px]">
{list.map((item, i) => (
<li
key={item.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${(i + 1) * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}}
>
<Link
href={item.href}
className="group relative block aspect-[410/717] overflow-hidden bg-[#f3f3f3]"
>
<Image
src={item.image}
alt={item.alt}
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
<div className="absolute inset-x-3 bottom-3 z-10 overflow-hidden bg-white px-3 py-2.5 text-center sm:inset-x-4 sm:bottom-4 sm:px-4 sm:py-3">
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<p className="relative z-10 font-doran text-base font-medium text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg lg:text-xl">
{item.nameFa}
</p>
<p
dir="ltr"
className="relative z-10 mt-0.5 font-montserrat text-[10px] font-light uppercase tracking-[0.28em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-[11px]"
>
{item.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CATEGORIES, type Category } from "@/data/home";
type CategoryStripProps = {
categories?: Category[];
};
export default function CategoryStrip({ categories }: CategoryStripProps) {
const list = categories?.length ? categories : CATEGORIES;
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1600px] px-4 py-20 sm:px-6 sm:py-24 lg:px-10 lg:py-32">
<ul
ref={ref}
className="flex flex-wrap items-stretch justify-center gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8"
>
{list.map((category, i) => (
<li
key={category.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${i * 90}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Link
href={category.href ?? "#"}
className="group relative flex w-[140px] flex-col items-center gap-2 overflow-hidden px-3 py-5 text-center sm:w-[160px] sm:px-4 sm:py-6 lg:w-[180px]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<div className="relative z-10 h-[72px] w-[68px] overflow-hidden sm:h-[80px] sm:w-[76px]">
<Image
src={category.image}
alt={category.nameFa}
fill
sizes="76px"
className="object-contain transition-[filter,transform] duration-[400ms] ease-out group-hover:invert"
/>
</div>
<div className="relative z-10 w-full">
<p className="whitespace-nowrap text-base font-light text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg">
{category.nameFa}
</p>
<p
dir="ltr"
className="whitespace-nowrap font-montserrat text-[11px] font-light uppercase tracking-[0.35em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-xs"
>
{category.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { FEATURES } from "@/data/home";
export default function FeaturesBar() {
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1200px] px-4 py-12 sm:px-6 lg:py-16">
<ul ref={ref} className="grid grid-cols-2 gap-8 lg:grid-cols-4 lg:gap-0">
{FEATURES.map((feature, index) => (
<li
key={feature.id}
className={`seq-fade-item flex flex-col items-center px-4 text-center ${
index < FEATURES.length - 1
? "lg:border-s lg:border-ink/15"
: ""
}`}
style={{
transitionDelay: visible ? `${index * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Image
src={feature.icon}
alt=""
width={44}
height={34}
unoptimized
className="mb-3 h-9 w-auto"
/>
<p className="font-doran text-lg font-medium tracking-tight text-ink sm:text-[22px]">
{feature.title}
</p>
<p className="mt-1 text-sm font-light text-ink/70 sm:text-[15px]">
{feature.subtitle}
</p>
</li>
))}
</ul>
</section>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { categoryPath } from "@/lib/category-path";
import { productPath } from "@/lib/product-path";
import { API_URL, STORE_SLUG } from "@/lib/public-api";
type SearchHit = {
id: number | string;
nameFa: string;
nameEn: string;
imageUrl?: string | null;
price?: number;
};
type CategoryHit = {
id: number;
nameFa: string;
nameEn: string;
};
export default function HeaderSearch() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<SearchHit[]>([]);
const [categories, setCategories] = useState<CategoryHit[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open) return;
const query = q.trim();
if (query.length < 2) {
setProducts([]);
setCategories([]);
return;
}
const t = window.setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
storeSlug: STORE_SLUG,
q: query,
limit: "8",
});
const res = await fetch(`${API_URL}/public/search?${params}`);
if (!res.ok) throw new Error("search failed");
const data = (await res.json()) as {
products?: SearchHit[];
categories?: CategoryHit[];
};
setProducts(data.products ?? []);
setCategories(data.categories ?? []);
} catch {
setProducts([]);
setCategories([]);
} finally {
setLoading(false);
}
}, 280);
return () => window.clearTimeout(t);
}, [q, open]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const query = q.trim();
if (!query) return;
setOpen(false);
window.location.href = `/products?q=${encodeURIComponent(query)}`;
}
return (
<>
<button
type="button"
aria-label="جستجو"
aria-expanded={open}
className="transition-opacity hover:opacity-70"
onClick={() => setOpen(true)}
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
{open ? (
<div className="fixed inset-0 z-[70]">
<button
type="button"
className="absolute inset-0 bg-ink/40"
aria-label="بستن جستجو"
onClick={() => setOpen(false)}
/>
<div
ref={panelRef}
className="absolute inset-x-0 top-0 bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
>
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-[900px] items-center gap-3 px-4 py-4 sm:px-6"
dir="rtl"
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={18}
height={18}
unoptimized
/>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی محصول یا دسته‌بندی..."
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
/>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-muted hover:text-ink"
>
بستن
</button>
</form>
<div className="mx-auto max-w-[900px] border-t border-line px-4 py-4 sm:px-6">
{loading ? (
<p className="text-sm text-muted">در حال جستجو...</p>
) : q.trim().length < 2 ? (
<p className="text-sm text-muted">
حداقل ۲ حرف وارد کنید.
</p>
) : !products.length && !categories.length ? (
<p className="text-sm text-muted">نتیجه‌ای یافت نشد.</p>
) : (
<div className="grid gap-6 sm:grid-cols-2">
{categories.length ? (
<div>
<p className="mb-2 text-xs text-muted">دسته‌بندی‌ها</p>
<ul className="space-y-1">
{categories.map((c) => (
<li key={c.id}>
<Link
href={categoryPath(c)}
onClick={() => setOpen(false)}
className="block border border-transparent px-2 py-2 text-sm hover:border-line hover:bg-[#f7f7f5]"
>
{c.nameFa}
{c.nameEn ? (
<span
dir="ltr"
className="ms-2 font-montserrat text-[10px] text-muted"
>
{c.nameEn}
</span>
) : null}
</Link>
</li>
))}
</ul>
</div>
) : null}
{products.length ? (
<div>
<p className="mb-2 text-xs text-muted">محصولات</p>
<ul className="space-y-1">
{products.map((p) => (
<li key={p.id}>
<Link
href={productPath(p)}
onClick={() => setOpen(false)}
className="flex items-center gap-3 border border-transparent px-2 py-2 hover:border-line hover:bg-[#f7f7f5]"
>
<span className="relative h-12 w-10 shrink-0 overflow-hidden bg-[#f3f3f1]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
p.imageUrl ||
"/images/home/products/coat-blue.jpg"
}
alt=""
className="h-full w-full object-cover"
/>
</span>
<span className="min-w-0">
<span className="block truncate text-sm text-ink">
{p.nameFa}
</span>
<span
dir="ltr"
className="block truncate font-montserrat text-[10px] text-muted"
>
{p.nameEn}
</span>
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { STORE_URL } from "@/lib/public-api";
/**
* Website (3000) and dashboards (3001) don't share localStorage on localhost.
* We still point at the store app: login when unknown, account/dashboard when a
* same-origin token exists (production shared domain), else login entry.
*/
export default function HeaderUserLink() {
const [href, setHref] = useState(`${STORE_URL}/login`);
const [label, setLabel] = useState("ورود");
useEffect(() => {
try {
const token = localStorage.getItem("mana_access_token");
const raw = localStorage.getItem("mana_user");
if (!token) {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
return;
}
let role = "";
if (raw) {
const user = JSON.parse(raw) as { role?: string };
role = user.role ?? "";
}
if (role === "STORE_ADMIN" || role === "SUPER_ADMIN") {
setHref(`${STORE_URL}/`);
setLabel("داشبورد");
} else {
setHref(`${STORE_URL}/account`);
setLabel("حساب کاربری");
}
} catch {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
}
}, []);
return (
<Link
href={href}
aria-label={label}
title={label}
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/user.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import HoverFillButton from "./HoverFillButton";
export type HeroSlideData = {
id: string;
image: string;
imagePosition?: string;
title: string;
subtitle: string;
cta: { label: string; href: string };
};
const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "autumn",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_20%]",
title: "برای بانویی که خودش انتخاب میکند.",
subtitle: "made for her",
cta: { label: "کالکشن پاییزه", href: "/products?collection=autumn" },
},
{
id: "new",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_40%]",
title: "جدیدترین‌های فصل همین حالا ببینید.",
subtitle: "new arrivals",
cta: { label: "جدیدترین‌ها", href: "/products?sort=newest" },
},
{
id: "sale",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_55%]",
title: "فروش ویژه تا ۷۰٪ تخفیف",
subtitle: "up to 70% off",
cta: { label: "مشاهده تخفیف‌ها", href: "/products?sale=1" },
},
];
const AUTO_MS = 10000;
type HeroSliderProps = {
slides?: HeroSlideData[];
};
export default function HeroSlider({ slides }: HeroSliderProps) {
const list = useMemo(
() => (slides?.length ? slides : FALLBACK_SLIDES),
[slides],
);
const [index, setIndex] = useState(0);
const [textKey, setTextKey] = useState(0);
const [paused, setPaused] = useState(false);
const slide = list[index] ?? list[0];
useEffect(() => {
setIndex(0);
}, [list]);
const goTo = useCallback((next: number) => {
setIndex(next);
setTextKey((k) => k + 1);
}, []);
const goPrev = () => goTo((index - 1 + list.length) % list.length);
const goNext = () => goTo((index + 1) % list.length);
useEffect(() => {
if (paused || list.length <= 1) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const t = window.setInterval(() => {
setIndex((i) => (i + 1) % list.length);
setTextKey((k) => k + 1);
}, AUTO_MS);
return () => window.clearInterval(t);
}, [index, paused, list.length]);
if (!slide) return null;
return (
<section
className="relative h-[min(100dvh,1081px)] min-h-[560px] w-full overflow-hidden bg-[#ebe6df]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{list.map((s, i) => {
const active = i === index;
return (
<div
key={s.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-out ${
active ? "opacity-100" : "opacity-0"
}`}
aria-hidden={!active}
>
<Image
key={active ? `on-${s.id}-${index}` : `off-${s.id}`}
src={s.image}
alt=""
fill
priority={i === 0}
sizes="100vw"
className={`object-cover ${s.imagePosition ?? "object-center"} ${
active ? "hero-ken-burns" : ""
} ${active && paused ? "hero-ken-burns-paused" : ""}`}
/>
</div>
);
})}
<div className="absolute inset-0 bg-gradient-to-l from-white/25 via-transparent to-transparent" />
<div className="absolute inset-y-0 right-16 z-10 flex w-[min(360px,calc(100%-6.5rem))] items-center sm:right-24 lg:right-[200px]">
<div
key={textKey}
dir="rtl"
className="hero-copy-in w-full pt-16 text-right"
>
<h1 className="hero-copy-item whitespace-pre-line font-doran text-right text-[clamp(1.25rem,2.2vw,2.25rem)] font-medium leading-[1.4] text-ink">
{slide.title}
</h1>
{slide.subtitle ? (
<p
dir="ltr"
className="hero-copy-item hero-copy-item-2 mt-3 block w-full whitespace-pre-line text-right font-montserrat text-[clamp(0.875rem,1.4vw,1.35rem)] font-light uppercase tracking-[0.28em] text-[#6d6d6d]"
>
{slide.subtitle}
</p>
) : null}
{slide.cta.label ? (
<div className="hero-copy-item hero-copy-item-3">
<HoverFillButton href={slide.cta.href || "#"} className="mt-7 w-full">
{slide.cta.label}
</HoverFillButton>
</div>
) : null}
</div>
</div>
{list.length > 1 ? (
<>
<div className="pointer-events-none absolute inset-y-0 left-4 z-20 flex items-center sm:left-8 lg:left-[120px]">
<button
type="button"
aria-label="اسلاید قبلی"
onClick={goPrev}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 right-4 z-20 flex items-center sm:right-8 lg:right-[120px]">
<button
type="button"
aria-label="اسلاید بعدی"
onClick={goNext}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div
dir="ltr"
className="absolute inset-x-0 bottom-8 z-20 flex items-center justify-center gap-2.5 sm:bottom-12"
role="tablist"
aria-label="اسلایدهای هیرو"
>
{list.map((s, i) => {
const active = i === index;
return (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`اسلاید ${i + 1}`}
onClick={() => goTo(i)}
className={`h-2 rounded-full transition-all duration-300 ${
active
? "w-8 bg-ink"
: "w-2 bg-ink/30 hover:bg-ink/50"
}`}
/>
);
})}
</div>
</>
) : null}
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
type HoverFillButtonProps = {
href: string;
children: ReactNode;
className?: string;
/** `light` = ink border on pale surfaces; `dark` = white border on ink surfaces */
variant?: "light" | "dark";
};
export default function HoverFillButton({
href,
children,
className = "",
variant = "light",
}: HoverFillButtonProps) {
const isDark = variant === "dark";
return (
<Link
href={href}
dir="rtl"
className={`group relative inline-flex h-[52px] items-center justify-between gap-3 overflow-hidden rounded-[5px] border px-4 backdrop-blur-sm sm:h-[56px] ${
isDark
? "border-white bg-transparent text-white"
: "border-ink bg-white/30 text-ink"
} ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 origin-bottom scale-y-0 transition-transform duration-[400ms] ease-out group-hover:scale-y-100 ${
isDark ? "bg-white" : "bg-ink"
}`}
/>
<span
className={`relative z-10 text-base font-light transition-colors duration-[400ms] ease-out sm:text-xl ${
isDark ? "group-hover:text-ink" : "group-hover:text-white"
}`}
>
{children}
</span>
<Image
src={
isDark
? "/images/home/icons/caret-left.svg"
: "/images/home/icons/caret-left-dark.svg"
}
alt=""
width={28}
height={28}
unoptimized
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert"
/>
</Link>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Image from "next/image";
import HoverFillButton from "./HoverFillButton";
type LeatherBannerProps = {
image?: string;
nameFa?: string;
nameEn?: string;
linkText?: string;
link?: string;
};
export default function LeatherBanner({
image = "/images/home/banners/leather.png",
nameFa = "هنر پوشیدن",
nameEn = "THE ART OF DRESSING",
linkText = "کالکشن چرم",
link = "/products?collection=leather",
}: LeatherBannerProps) {
const enLines = nameEn.trim().split(/\n+/).filter(Boolean);
const line1 = enLines[0] ?? "THE ART";
const line2 = enLines[1] ?? (enLines.length === 1 ? "" : "OF DRESSING");
// If single-line English like "THE ART OF DRESSING", split roughly in half
const [display1, display2] =
enLines.length === 1 && line1.includes(" ")
? (() => {
const parts = line1.split(/\s+/);
const mid = Math.ceil(parts.length / 2);
return [parts.slice(0, mid).join(" "), parts.slice(mid).join(" ")];
})()
: [line1, line2 || "OF DRESSING"];
return (
<section className="relative w-full overflow-hidden bg-white">
<div className="relative mx-auto w-full max-w-[1920px] aspect-[1920/585]">
<Image
src={image}
alt={nameFa}
fill
sizes="100vw"
className="object-cover object-left-bottom"
/>
<div className="absolute inset-y-0 right-4 z-10 flex items-center sm:right-10 lg:right-[max(2rem,calc((100%-1709px)/2+186px))]">
<div
dir="ltr"
className="flex w-max max-w-[min(100%,42vw)] flex-col items-stretch text-left text-ink"
>
<p className="whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display1}
</p>
{display2 ? (
<p className="mt-1 whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display2}
</p>
) : null}
<p
dir="rtl"
className="mt-3 whitespace-nowrap font-doran text-[clamp(1.25rem,3.2vw,3.5rem)] font-light leading-none"
>
{nameFa}
</p>
{linkText ? (
<HoverFillButton
href={link || "#"}
className="mt-4 w-full !h-11 sm:!h-12"
>
{linkText}
</HoverFillButton>
) : null}
</div>
</div>
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import HoverFillButton from "./HoverFillButton";
export default function PartnerCta() {
return (
<section dir="rtl" className="bg-ink text-white">
<div className="mx-auto flex max-w-[1920px] flex-col items-stretch justify-between gap-6 px-4 py-14 sm:px-6 lg:flex-row lg:items-center lg:px-[120px] lg:py-16">
<div className="text-right lg:max-w-[560px]">
<p className="font-doran text-base font-normal sm:text-lg">
مایل هستید ما محصول شما را برای شما بفروشیم؟
</p>
<p className="mt-1.5 text-sm font-light text-white/85 sm:text-base">
اگر تولید کننده هستید، ما مایل به همکاری با شما هستیم
</p>
</div>
<HoverFillButton
href="/partners"
variant="dark"
className="!h-10 w-fit max-w-none shrink-0 gap-2 self-start px-3 sm:!h-11 sm:self-center [&_span]:!text-sm sm:[&_span]:!text-base [&_img]:!h-4 [&_img]:!w-4"
>
شروع به همکاری
</HoverFillButton>
</div>
</section>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Image from "next/image";
import Link from "next/link";
import type { Product } from "@/data/home";
import { productPath } from "@/lib/product-path";
type ProductCardProps = {
product: Product;
className?: string;
};
export default function ProductCard({ product, className = "" }: ProductCardProps) {
const href = product.href ?? productPath(product);
const hoverSrc = product.imageHover;
return (
<article
dir="rtl"
className={`group relative flex h-[491px] w-full flex-col rounded-[5px] border border-[#8e8e8e]/30 bg-white transition-colors duration-300 ease-out hover:border-ink ${className}`}
>
<Link href={href} className="flex min-h-0 flex-1 flex-col">
{/* Clip media only — keep border on the outer box so it never gets cut off */}
<span className="relative block min-h-0 flex-1 overflow-hidden rounded-t-[4px] p-2.5">
<span className="relative block h-full w-full overflow-hidden rounded-[5px]">
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 324px"
className={`object-cover object-top transition-transform duration-500 ease-out ${
hoverSrc ? "group-hover:translate-x-full" : "group-hover:scale-[1.04]"
}`}
/>
{hoverSrc ? (
<Image
src={hoverSrc}
alt=""
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-top -translate-x-full transition-transform duration-500 ease-out group-hover:translate-x-0"
aria-hidden
/>
) : null}
</span>
</span>
<span className="flex shrink-0 flex-col gap-2 px-3.5 pb-3.5 pt-1">
<span className="w-full text-right">
<span className="line-clamp-1 block text-[15px] font-medium text-ink">
{product.nameFa}
</span>
<span
dir="ltr"
className="mt-0.5 line-clamp-1 block w-full text-right font-montserrat text-[9px] font-light uppercase tracking-[0.2em] text-[#afafaf]"
>
{product.nameEn}
</span>
</span>
<span
dir="ltr"
className="mt-1 flex items-baseline justify-start gap-1.5 border-t border-ink/15 pt-3 pb-0.5 transition-colors duration-300 ease-out group-hover:border-ink"
>
<span className="font-montserrat text-base font-normal leading-none text-ink sm:text-[17px]">
{product.price}
</span>
<span className="text-[9px] leading-none text-[#878787]">IRT</span>
</span>
</span>
</Link>
<button
type="button"
aria-label="افزودن به علاقه‌مندی"
className="absolute end-3 top-4 z-10 transition-opacity hover:opacity-70"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Image
src="/images/home/icons/bookmark.svg"
alt=""
width={24}
height={24}
unoptimized
/>
</button>
</article>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "./ProductCard";
import PromoCard, { type PromoSlotOverride } from "./PromoCard";
type ProductRowProps = {
products: Product[];
promo: "sale" | "discount" | "autumn";
className?: string;
/** Stagger fade-in for promo + product cards when scrolled into view */
stagger?: boolean;
promoSlot?: PromoSlotOverride | null;
};
const CARD_W =
"w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
export default function ProductRow({
products,
promo,
className = "",
stagger = false,
promoSlot,
}: ProductRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const offsetRef = useRef(0);
const maxRef = useRef(0);
const stepRef = useRef(288);
const [offset, setOffset] = useState(0);
const [maxOffset, setMaxOffset] = useState(0);
const [visible, setVisible] = useState(!stagger);
const dragRef = useRef<{
pointerId: number | null;
startX: number;
startOffset: number;
dragging: boolean;
}>({ pointerId: null, startX: 0, startOffset: 0, dragging: false });
const applyOffset = useCallback((value: number) => {
const next = Math.min(maxRef.current, Math.max(0, value));
offsetRef.current = next;
setOffset(next);
}, []);
const measure = useCallback(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const card = track.querySelector<HTMLElement>("[data-carousel-card]");
const styles = getComputedStyle(track);
const gap = parseFloat(styles.columnGap || styles.gap || "8") || 8;
const cardW = card?.offsetWidth ?? 280;
stepRef.current = cardW + gap;
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
setMaxOffset(maxRef.current);
applyOffset(offsetRef.current);
}, [applyOffset]);
useEffect(() => {
measure();
const viewport = viewportRef.current;
if (!viewport) return;
const ro = new ResizeObserver(() => measure());
ro.observe(viewport);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
const t = window.setTimeout(measure, 200);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products, promo]);
useEffect(() => {
if (!stagger) return;
const el = sectionRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, [stagger]);
const go = (direction: "prev" | "next") => {
const delta = direction === "next" ? stepRef.current : -stepRef.current;
applyOffset(offsetRef.current + delta);
};
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
// Never steal pointer from links/buttons — cards are full <a> links
if (target.closest("a, button")) return;
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startOffset: offsetRef.current,
dragging: false,
};
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const dx = drag.startX - e.clientX;
if (!drag.dragging) {
// Wait for a real drag before capturing — plain clicks still hit <a>
if (Math.abs(e.clientX - drag.startX) < 8) return;
drag.dragging = true;
viewport.setPointerCapture(e.pointerId);
if (trackRef.current) trackRef.current.style.transition = "none";
}
applyOffset(drag.startOffset + dx);
e.preventDefault();
};
const onPointerUp = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const wasDragging = drag.dragging;
drag.pointerId = null;
drag.dragging = false;
if (trackRef.current) trackRef.current.style.transition = "";
if (wasDragging) {
try {
viewport.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
const suppress = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
viewport.removeEventListener("click", suppress, true);
};
viewport.addEventListener("click", suppress, true);
window.setTimeout(
() => viewport.removeEventListener("click", suppress, true),
0,
);
}
};
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove, { passive: false });
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
return () => {
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
};
}, [applyOffset]);
return (
<section
ref={sectionRef}
dir="rtl"
className={`relative w-full pl-4 sm:pl-6 lg:pl-[120px] pr-8 sm:pr-12 lg:pr-[180px] ${className}`}
>
<div className="flex items-stretch gap-2 sm:gap-2.5 lg:gap-3">
<div
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<PromoCard variant={promo} className="max-w-none" slot={promoSlot} />
</div>
<div
ref={viewportRef}
className="relative min-w-0 flex-1 overflow-hidden touch-pan-y"
>
<div
ref={trackRef}
className="flex cursor-grab items-stretch gap-2 transition-transform duration-300 ease-out will-change-transform active:cursor-grabbing sm:gap-2.5 lg:gap-3"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
>
{products.map((product, i) => (
<div
key={`${promo}-${product.id}`}
data-carousel-card
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? `${(i + 1) * 100}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<ProductCard product={product} className="max-w-none" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-y-0 right-1 z-10 flex items-center sm:right-2">
<button
type="button"
aria-label="محصول قبلی"
disabled={offset <= 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("prev");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 left-1 z-10 flex items-center sm:left-2">
<button
type="button"
aria-label="محصول بعدی"
disabled={offset >= maxOffset - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("next");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
</div>
</div>
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { MEGA_MENU_TABS } from "@/data/home";
export type MegaColumn = {
title: string;
href: string;
links: readonly { label: string; href: string }[];
};
export type MegaTabData = {
id: string;
nameFa: string;
nameEn: string;
href: string;
image: string;
/** Second-level categories as columns, each with third-level children */
columns?: readonly MegaColumn[];
/** Flat fallback when columns are not provided */
links?: readonly { label: string; href: string }[];
};
type ProductsMegaMenuProps = {
open: boolean;
onClose: () => void;
tabs?: MegaTabData[];
};
function columnsForTab(tab: MegaTabData): MegaColumn[] {
if (tab.columns?.length) return [...tab.columns];
if (tab.links?.length) {
return [
{
title: tab.nameFa,
href: tab.href,
links: tab.links,
},
];
}
return [];
}
export default function ProductsMegaMenu({
open,
onClose,
tabs,
}: ProductsMegaMenuProps) {
const list = (tabs?.length ? tabs : MEGA_MENU_TABS) as MegaTabData[];
const [activeId, setActiveId] = useState(list[0]?.id ?? "coat");
const active = list.find((t) => t.id === activeId) ?? list[0];
if (!open || !active) return null;
const columns = columnsForTab(active);
return (
<div
id="products-mega-menu"
role="region"
aria-label="منوی محصولات"
className="pointer-events-auto absolute inset-x-0 top-full z-40 border-t border-ink/5 bg-white shadow-[0_16px_40px_rgba(0,0,0,0.1)]"
onMouseLeave={onClose}
>
<div className="mx-auto max-w-[1709px] px-4 py-5 sm:px-6 lg:px-10 lg:py-6">
<div
role="tablist"
aria-label="دسته‌بندی محصولات"
className="flex flex-wrap items-end justify-start gap-x-1 gap-y-1 border-b border-ink/10"
>
{list.map((tab) => {
const selected = tab.id === active.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
id={`mega-tab-${tab.id}`}
aria-controls={`mega-panel-${tab.id}`}
onMouseEnter={() => setActiveId(tab.id)}
onFocus={() => setActiveId(tab.id)}
onClick={() => setActiveId(tab.id)}
className={`-mb-px border-b-2 px-3 py-2.5 text-center transition-colors sm:px-4 ${
selected
? "border-ink"
: "border-transparent hover:opacity-90"
}`}
>
<span
className={`block font-doran text-sm font-medium sm:text-[15px] ${
selected ? "text-ink" : "text-ink/80"
}`}
>
{tab.nameFa}
</span>
<span
dir="ltr"
className={`mt-0.5 block font-montserrat text-[9px] font-light uppercase tracking-[0.22em] sm:text-[10px] ${
selected ? "text-ink/70" : "text-ink/40"
}`}
>
{tab.nameEn}
</span>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`mega-panel-${active.id}`}
aria-labelledby={`mega-tab-${active.id}`}
className="mt-5 flex items-start justify-start gap-6 sm:gap-8 lg:gap-10"
>
<div className="min-w-0 flex-1 pt-1">
<div className="grid grid-cols-2 gap-x-6 gap-y-7 sm:grid-cols-3 sm:gap-x-8 lg:grid-cols-4 xl:grid-cols-5 xl:gap-x-10">
{columns.map((col) => (
<div key={col.href + col.title} className="min-w-0">
<Link
href={col.href}
onClick={onClose}
className="block text-sm font-medium text-ink transition-opacity hover:opacity-70 sm:text-[15px]"
>
{col.title}
</Link>
{col.links.length > 0 ? (
<ul className="mt-2.5 flex flex-col gap-1.5 text-[13px] font-light text-ink/70 sm:text-sm">
{col.links.map((link) => (
<li key={link.href + link.label}>
<Link
href={link.href}
onClick={onClose}
className="transition-colors hover:text-ink"
>
{link.label}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
</div>
<div className="relative hidden h-[160px] w-[120px] shrink-0 overflow-hidden bg-[#f3f3f3] sm:block sm:h-[200px] sm:w-[150px] lg:h-[220px] lg:w-[160px]">
{list.map((tab) => (
<div
key={tab.id}
className={`absolute inset-0 transition-opacity duration-[400ms] ease-out ${
tab.id === active.id ? "opacity-100" : "opacity-0"
}`}
aria-hidden={tab.id !== active.id}
>
<Image
src={tab.image}
alt=""
fill
sizes="160px"
className="object-cover"
/>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import Image from "next/image";
import Link from "next/link";
type PromoVariant = "sale" | "discount" | "autumn";
export type PromoSlotOverride = {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
};
type PromoCardProps = {
variant: PromoVariant;
href?: string;
className?: string;
slot?: PromoSlotOverride | null;
};
const META: Record<
PromoVariant,
{ href: string; labelFa: string }
> = {
sale: { href: "/products?sale=1", labelFa: "" },
discount: { href: "/products?sale=1", labelFa: "بهترین مانتوهای ما" },
autumn: {
href: "/products?collection=autumn",
labelFa: "کالکشن پاییزه",
},
};
const CARD_SHELL =
"relative flex h-[491px] w-full flex-col overflow-hidden rounded-[19px] border border-[#8e8e8e]/30";
/** Full-bleed creative when Website Images provides a slot image. */
function FullImagePromo({
href,
image,
alt,
className,
}: {
href: string;
image: string;
alt: string;
className: string;
}) {
return (
<Link href={href} className={`${CARD_SHELL} bg-white ${className}`}>
<Image
src={image}
alt={alt}
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-center"
/>
</Link>
);
}
export default function PromoCard({
variant,
href,
className = "",
slot,
}: PromoCardProps) {
const meta = META[variant];
const to = slot?.link || href || meta.href;
if (slot?.image) {
return (
<FullImagePromo
href={to}
image={slot.image}
alt={slot.nameFa || slot.nameEn || slot.subtext || "promo"}
className={className}
/>
);
}
if (variant === "sale") {
return (
<Link href={to} dir="ltr" className={`${CARD_SHELL} bg-ink text-white ${className}`}>
<div className="relative flex flex-1 items-center justify-center px-6 pt-10">
<div className="relative h-[200px] w-[140px]">
<Image
src="/images/home/banners/sale-script.png"
alt=""
fill
className="object-contain opacity-45"
sizes="140px"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/images/home/icons/sale-wordmark.svg"
alt="Sale"
width={200}
height={100}
unoptimized
className="h-auto w-[170px] max-w-none"
/>
</div>
</div>
</div>
<div className="px-6 pb-8 text-center">
<p className="whitespace-nowrap font-montserrat text-[15px] font-normal tracking-[0.2em] uppercase sm:text-lg">
Our Season Finale
</p>
<p className="mt-1 whitespace-nowrap font-montserrat text-xl font-semibold tracking-[0.18em] uppercase sm:text-2xl">
Up to 70% OFF
</p>
</div>
</Link>
);
}
if (variant === "discount") {
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 text-ink sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 flex-col items-center justify-center text-center uppercase">
<p className="whitespace-nowrap text-[24px] font-light tracking-[0.7em] sm:text-[30px] sm:tracking-[0.84em]">
Up TO
</p>
<p className="-mt-1 font-montserrat text-[64px] font-medium leading-none tracking-[0.12em] sm:text-[80px] sm:tracking-[0.19em]">
70%
</p>
<p className="-mt-1 whitespace-nowrap font-montserrat text-[24px] font-light tracking-[0.1em] sm:text-[30px] sm:tracking-[0.13em]">
Discount
</p>
</div>
<p className="text-center text-[18px] font-light sm:text-[23px]">
{slot?.nameFa || meta.labelFa}
</p>
</Link>
);
}
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 items-center justify-center">
<div className="relative size-[140px] sm:size-[160px]">
<Image
src="/images/home/banners/autumn-leaf.png"
alt=""
fill
className="object-contain mix-blend-darken"
sizes="160px"
/>
</div>
</div>
<div className="flex flex-col items-center text-center uppercase">
<p className="whitespace-nowrap font-montserrat text-[18px] font-light tracking-[0.04em]">
i wanted it for
</p>
<p className="font-montserrat text-[32px] font-medium tracking-[0.2em]">
Autumn
</p>
<p className="mt-3 text-[18px] font-light normal-case sm:text-[20px]">
{slot?.nameFa || meta.labelFa}
</p>
</div>
</Link>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
/** Stagger delay in ms once visible */
delay?: number;
/** Optional custom y offset in px */
offset?: number;
};
export default function Reveal({
children,
className = "",
delay = 0,
offset = 36,
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.12, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : `translate3d(0,${offset}px,0)`,
filter: visible ? "blur(0)" : "blur(8px)",
transition: `opacity 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, filter 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
willChange: "opacity, transform, filter",
}}
>
{children}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import Image from "next/image";
import Link from "next/link";
import {
FOOTER_CATEGORIES_COL1,
FOOTER_CATEGORIES_COL2,
FOOTER_STORE_LINKS,
SOCIAL_LINKS,
} from "@/data/home";
type SiteFooterProps = {
paymentBadges?: string;
};
export default function SiteFooter({
paymentBadges = "/images/home/banners/payment-badges.png",
}: SiteFooterProps) {
return (
<footer dir="rtl" className="mt-8">
<div className="bg-[#ebebeb]">
<div className="mx-auto flex max-w-[1920px] flex-col items-center justify-between gap-4 px-4 py-5 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="max-w-xl text-center text-sm font-light text-ink sm:text-start sm:text-base">
برای اطلاع از جدیدترین محصولات و تخفیفات، ما را در شبکه‌های اجتماعی
دنبال کنید.
</p>
<ul className="flex items-center gap-5 sm:gap-6">
{SOCIAL_LINKS.map((social) => (
<li key={social.id}>
<a
href={social.href}
aria-label={social.label}
className="transition-opacity hover:opacity-70"
>
<Image
src={social.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</a>
</li>
))}
</ul>
</div>
</div>
<div id="contact" className="scroll-mt-24 bg-[#242424] text-white">
<div className="mx-auto grid max-w-[1920px] gap-8 px-4 py-10 sm:px-6 lg:grid-cols-4 lg:gap-8 lg:px-[120px] lg:py-12">
{/* Start (right in RTL): contact */}
<div className="text-start">
<h3 className="mb-4 text-base font-medium sm:text-lg">
اطلاعات تماس:
</h3>
<div className="space-y-2 text-sm font-light leading-relaxed sm:text-[15px]">
<p>
<span className="text-[#a6a6a6]">آدرس: </span>
تهران، خیابان ولیعصر بالاتر از پارک ساعی، پلاک ۲۴۸
</p>
<p>
<span className="text-[#a6a6a6]">تلفن فروشگاه: </span>
<span dir="ltr">021-22423600</span>
</p>
<p>
<span className="text-[#a6a6a6]">تلفن همراه: </span>
<span dir="ltr">09122504800</span>
</p>
<p>
<span className="text-[#a6a6a6]">آدرس ایمیل: </span>
<span dir="ltr">Info@ManaCollecitions.com</span>
</p>
</div>
</div>
{/* Store */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">فروشگاه</h3>
<ul className="space-y-2 text-sm font-light sm:text-[15px]">
{FOOTER_STORE_LINKS.map((link) => (
<li key={link.label}>
<Link href={link.href} className="hover:opacity-70">
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Categories */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">
دسته بندی محصولات
</h3>
<div className="grid grid-cols-2 gap-x-6 text-sm font-light text-[#e1e1e1] sm:text-[15px]">
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL1.map((label) => (
<li key={label}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL2.map((label) => (
<li key={`c2-${label}`}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* End (left in RTL): map */}
<div className="min-h-[180px] overflow-hidden rounded-[4px] bg-[#1a1a1a] sm:min-h-[200px]">
<iframe
title="موقعیت فروشگاه مانا کالکشن"
src="https://maps.google.com/maps?q=Tehran%20Valiasr%20Street%20above%20Saei%20Park%20No%20248&z=16&output=embed"
className="h-full min-h-[180px] w-full border-0 [filter:invert(92%)_hue-rotate(180deg)_brightness(0.9)_contrast(0.95)] sm:min-h-[200px]"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
</div>
</div>
<div className="bg-black text-white">
<div className="mx-auto flex max-w-[1920px] flex-col-reverse items-center justify-between gap-4 px-4 py-4 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="text-center text-xs font-light sm:text-start sm:text-sm">
تمامی حق و حقوق مادی معنوی وبسایت متعلق به شرکت مانا کالکشن می‌باشد.
۱۴۰۴-۱۴۰۵
</p>
<div className="relative h-8 w-[160px] sm:h-9 sm:w-[190px]">
<Image
src={paymentBadges}
alt="روش‌های پرداخت"
fill
className="object-contain object-end"
sizes="190px"
/>
</div>
</div>
</div>
</footer>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { NAV_LINKS } from "@/data/home";
import HeaderSearch from "./HeaderSearch";
import HeaderUserLink from "./HeaderUserLink";
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
type SiteHeaderProps = {
megaTabs?: MegaTabData[];
};
export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
const [scrolled, setScrolled] = useState(false);
const [megaOpen, setMegaOpen] = useState(false);
const closeTimer = useRef<number | null>(null);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 24);
setMegaOpen(false);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!megaOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMegaOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [megaOpen]);
const openMega = () => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
setMegaOpen(true);
};
const scheduleClose = () => {
if (closeTimer.current) window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setMegaOpen(false), 120);
};
return (
<header
className={`pointer-events-none z-50 transition-[padding,background-color] duration-300 ease-out ${
scrolled
? "fixed inset-x-0 top-0 bg-white px-0 pt-0 shadow-[0_1px_0_rgba(0,0,0,0.06)]"
: "absolute inset-x-0 top-0 px-0 pt-6 sm:pt-8 lg:pt-14"
}`}
>
<div
className={`relative transition-[padding] duration-300 ease-out ${
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
}`}
>
<div
className={`pointer-events-auto mx-auto flex items-center justify-between gap-3 transition-[height,border-radius,background-color,backdrop-filter,border-color,max-width,padding] duration-300 ease-out ${
scrolled
? "h-[56px] w-full max-w-none rounded-none border-0 bg-white px-4 sm:h-[64px] sm:px-6 lg:px-10"
: "h-[60px] max-w-[1709px] rounded-[5px] border border-white/20 bg-white/20 px-4 backdrop-blur-[25px] sm:h-[80px] sm:px-6 lg:px-8"
}`}
>
<Link
href="/"
aria-label="Mana Collection"
className={`flex shrink-0 items-center justify-center rounded-[2px] border border-ink transition-[width,height] duration-300 ${
scrolled ? "size-9 sm:size-10" : "size-11 sm:size-[52px]"
}`}
>
<span
className={`font-montserrat font-light leading-none transition-[font-size] duration-300 ${
scrolled
? "text-[20px] sm:text-[24px]"
: "text-[26px] sm:text-[32px]"
}`}
>
M
</span>
</Link>
<nav className="hidden items-center gap-5 lg:flex xl:gap-6">
{NAV_LINKS.map((link) => {
const isProducts = "hasDropdown" in link && link.hasDropdown;
if (isProducts) {
return (
<div
key={link.label}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
>
<button
type="button"
aria-expanded={megaOpen}
aria-haspopup="true"
aria-controls="products-mega-menu"
onClick={() => setMegaOpen((v) => !v)}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
<Image
src="/images/home/icons/caret-down.svg"
alt=""
width={16}
height={16}
unoptimized
className={`transition-transform duration-300 ${
megaOpen ? "rotate-180" : ""
}`}
/>
</button>
</div>
);
}
return (
<Link
key={link.label}
href={link.href}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
</Link>
);
})}
</nav>
<div dir="ltr" className="flex items-center gap-3.5 sm:gap-4">
<HeaderUserLink />
<Link
href="/cart"
aria-label="سبد خرید"
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/bag.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
<HeaderSearch />
</div>
</div>
<div
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
className={scrolled ? "" : "px-0"}
>
<ProductsMegaMenu
open={megaOpen}
onClose={() => setMegaOpen(false)}
tabs={megaTabs}
/>
</div>
</div>
</header>
);
}
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
type ImageLightboxProps = {
images: string[];
index: number;
alt: string;
open: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
};
export default function ImageLightbox({
images,
index,
alt,
open,
onClose,
onIndexChange,
}: ImageLightboxProps) {
const count = images.length;
const go = useCallback(
(dir: -1 | 1) => {
if (!count) return;
onIndexChange((index + dir + count) % count);
},
[count, index, onIndexChange],
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") go(1);
if (e.key === "ArrowRight") go(-1);
};
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose, go]);
if (!open || !images[index]) return null;
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
>
<button
type="button"
className="absolute inset-0 cursor-zoom-out"
aria-label="بستن"
onClick={onClose}
/>
<div className="relative z-10 flex w-full max-w-5xl flex-col gap-3">
<div className="flex items-center justify-between gap-3 text-white">
<p dir="ltr" className="font-montserrat text-xs tracking-wide">
{index + 1} / {count}
</p>
<button
type="button"
onClick={onClose}
className="flex h-11 w-11 items-center justify-center rounded-[5px] border border-white/40 text-2xl leading-none transition-colors hover:bg-white hover:text-ink"
aria-label="بستن"
>
×
</button>
</div>
<div className="relative aspect-[3/4] w-full overflow-hidden sm:aspect-[4/5] lg:aspect-[16/11]">
<Image
src={images[index]}
alt={alt}
fill
priority
sizes="(max-width: 1024px) 100vw, 960px"
className="object-contain"
/>
</div>
{count > 1 ? (
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => go(-1)}
className="rounded-[5px] border border-white/40 px-4 py-2 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
بعدی
</button>
<div className="flex gap-2">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
type="button"
onClick={() => onIndexChange(i)}
className={`relative h-14 w-11 overflow-hidden rounded-[4px] border ${
i === index ? "border-white" : "border-white/30 opacity-70"
}`}
aria-label={`تصویر ${i + 1}`}
>
<Image src={src} alt="" fill className="object-cover" sizes="44px" />
</button>
))}
</div>
<button
type="button"
onClick={() => go(1)}
className="rounded-[5px] border border-white/40 px-4 py-2 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
قبلی
</button>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import Image from "next/image";
import { useMemo, useState } from "react";
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
type ProductBuyBoxProps = {
product: ProductDetail;
};
function OptionRow({
labelFa,
labelEn,
options,
value,
onChange,
kind,
}: {
labelFa: string;
labelEn: string;
options: ProductVariantOption[];
value: string;
onChange: (id: string) => void;
kind: "color" | "size" | "package";
}) {
if (!options.length) return null;
return (
<div className="space-y-2.5">
<div className="flex items-baseline justify-between gap-3">
<p className="text-sm text-ink">{labelFa}</p>
<p dir="ltr" className="font-montserrat text-[10px] tracking-[0.14em] text-muted uppercase">
{labelEn}
</p>
</div>
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const selected = value === opt.id;
const disabled = opt.available === false;
const tip = opt.labelEn || opt.labelFa;
if (kind === "color") {
return (
<button
key={opt.id}
type="button"
disabled={disabled}
title={tip}
aria-label={tip}
onClick={() => onChange(opt.id)}
className={`group/swatch relative h-9 w-9 rounded-full border transition-transform ${
selected ? "border-ink scale-105" : "border-line"
} ${disabled ? "opacity-35 cursor-not-allowed" : "hover:border-ink"}`}
aria-pressed={selected}
>
<span
className="absolute inset-1 rounded-full border border-black/5"
style={{ background: opt.swatch || "#ccc" }}
/>
<span
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-[3px] bg-ink px-2 py-1 font-montserrat text-[10px] uppercase tracking-[0.08em] text-white opacity-0 transition-opacity group-hover/swatch:opacity-100"
>
{tip}
</span>
</button>
);
}
return (
<button
key={opt.id}
type="button"
disabled={disabled}
title={tip}
aria-label={tip}
onClick={() => onChange(opt.id)}
className={`min-w-[2.75rem] border px-3 py-2 text-sm uppercase tracking-[0.04em] transition-colors ${
selected
? "border-ink bg-ink text-white"
: "border-line bg-white text-ink hover:border-ink"
} ${disabled ? "opacity-35 cursor-not-allowed line-through" : ""}`}
aria-pressed={selected}
>
{kind === "size" ? opt.labelEn.toUpperCase() : opt.labelFa}
</button>
);
})}
</div>
</div>
);
}
export default function ProductBuyBox({ product }: ProductBuyBoxProps) {
const firstColor =
product.colors.find((c) => c.available !== false)?.id ??
product.colors[0]?.id ??
"";
const firstSize =
product.sizes.find((s) => s.available !== false)?.id ??
product.sizes[0]?.id ??
"";
const firstPackage =
product.packages.find((p) => p.available !== false)?.id ??
product.packages[0]?.id ??
"";
const [color, setColor] = useState(firstColor);
const [size, setSize] = useState(firstSize);
const [pkg, setPkg] = useState(firstPackage);
const selectedColor = useMemo(
() => product.colors.find((c) => c.id === color),
[color, product.colors],
);
return (
<div className="flex flex-col gap-6 lg:gap-8">
<div className="text-right">
<h2 className="font-doran text-[clamp(1.5rem,2.5vw,2rem)] font-medium text-ink">
{product.nameFa}
</h2>
<p
dir="ltr"
className="mt-2 text-right font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted"
>
{product.nameEn}
</p>
<p className="mt-4 text-sm leading-7 text-ink/75">{product.summary}</p>
</div>
<div className="space-y-5">
<OptionRow
labelFa={`رنگ${selectedColor ? ` · ${selectedColor.labelFa}` : ""}`}
labelEn="Color"
options={product.colors}
value={color}
onChange={setColor}
kind="color"
/>
<OptionRow
labelFa="سایز"
labelEn="Size"
options={product.sizes}
value={size}
onChange={setSize}
kind="size"
/>
<OptionRow
labelFa="بسته‌بندی"
labelEn="Package"
options={product.packages}
value={pkg}
onChange={setPkg}
kind="package"
/>
</div>
<div dir="ltr" className="flex items-baseline gap-2 border-y border-line py-4">
<p className="font-montserrat text-2xl text-ink sm:text-3xl">
{product.price}
</p>
<p className="text-xs text-muted">IRT</p>
</div>
{/* Sizing guide frame */}
<div className="relative overflow-hidden rounded-[5px] border border-dashed border-ink/25 bg-[#f7f7f5]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-60"
style={{
backgroundImage:
"radial-gradient(circle, rgba(10,10,10,0.12) 0.7px, transparent 1px)",
backgroundSize: "12px 12px",
}}
/>
<div className="relative grid gap-4 p-4 sm:grid-cols-[minmax(0,1fr)_140px] sm:items-center">
<div>
<p className="font-doran text-lg text-ink">راهنمای سایز</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Sizing guide
</p>
<p className="mt-2 text-xs leading-6 text-ink/70">
{product.sizeGuideNote}
</p>
<a
href="#product-tabs"
className="mt-3 inline-flex text-sm text-ink underline underline-offset-4 hover:opacity-70"
>
مشاهده جدول اندازه‌ها
</a>
</div>
<div className="relative mx-auto aspect-[3/4] w-full max-w-[140px] overflow-hidden rounded-[5px] border border-line bg-white">
{product.sizeGuideImage ? (
<Image
src={product.sizeGuideImage}
alt=""
fill
sizes="140px"
className="object-cover object-top opacity-90"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[11px] text-muted">
Size chart
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
className="group relative inline-flex h-12 flex-1 items-center justify-center overflow-hidden rounded-[5px] border border-ink bg-ink px-4 text-white"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-white transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<span className="relative z-10 text-sm transition-colors duration-[400ms] group-hover:text-ink sm:text-base">
افزودن به سبد
</span>
</button>
<button
type="button"
className="inline-flex h-12 items-center justify-center gap-2 rounded-[5px] border border-line px-4 text-sm text-ink transition-colors hover:border-ink"
aria-label="افزودن به علاقه‌مندی"
>
<Image
src="/images/home/icons/bookmark.svg"
alt=""
width={20}
height={20}
unoptimized
/>
علاقه‌مندی
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import Image from "next/image";
import Link from "next/link";
import { LIST_PAGE_META } from "@/data/products";
type Crumb = { label: string; href?: string };
type ProductDetailHeroProps = {
heroImage?: string;
breadcrumbs: readonly Crumb[];
};
/** Shorter list-style title band — home hero image, breadcrumbs only. */
export default function ProductDetailHero({
heroImage = LIST_PAGE_META.heroImage,
breadcrumbs,
}: ProductDetailHeroProps) {
return (
<section className="relative h-[min(28vw,220px)] min-h-[140px] w-full overflow-hidden bg-[#ebe6df] sm:min-h-[160px]">
<Image
src={heroImage}
alt=""
fill
priority
sizes="100vw"
className="object-cover object-[center_20%]"
/>
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
<div className="absolute inset-x-0 bottom-4 z-10 px-4 sm:bottom-5 sm:px-6 lg:bottom-6 lg:px-[120px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex flex-wrap items-center gap-1.5 text-[11px] font-light text-ink sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span
key={`${crumb.label}-${i}`}
className="inline-flex items-center gap-1.5 sm:gap-2"
>
{i > 0 ? (
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={12}
height={12}
unoptimized
/>
) : null}
{crumb.href ? (
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
</div>
</section>
);
}
@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import ImageLightbox from "./ImageLightbox";
type ProductGalleryProps = {
images: string[];
alt: string;
};
export default function ProductGallery({ images, alt }: ProductGalleryProps) {
const gallery = images.length ? images : ["/images/home/products/coat-blue.jpg"];
const [active, setActive] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="group relative aspect-[3/4] w-full overflow-hidden rounded-[5px] border border-line bg-[#f6f6f4] text-start"
aria-label="باز کردن لایت‌باکس"
>
<Image
src={gallery[active]}
alt={alt}
fill
priority
sizes="(max-width: 1024px) 100vw, 560px"
className="object-cover object-top transition-transform duration-500 ease-out group-hover:scale-[1.02]"
/>
<span className="absolute bottom-3 start-3 rounded-[4px] border border-white/50 bg-ink/50 px-2.5 py-1 text-[11px] text-white backdrop-blur-sm">
بزرگ‌نمایی
</span>
</button>
{gallery.length > 1 ? (
<ul className="grid grid-cols-4 gap-2 sm:gap-3">
{gallery.map((src, i) => (
<li key={`${src}-${i}`}>
<button
type="button"
onClick={() => setActive(i)}
onDoubleClick={() => {
setActive(i);
setLightboxOpen(true);
}}
className={`relative aspect-[3/4] w-full overflow-hidden rounded-[5px] border transition-colors ${
i === active
? "border-ink"
: "border-line hover:border-ink/50"
}`}
aria-label={`پیش‌نمایش ${i + 1}`}
aria-current={i === active}
>
<Image
src={src}
alt=""
fill
sizes="120px"
className="object-cover object-top"
/>
</button>
</li>
))}
</ul>
) : null}
<ImageLightbox
images={gallery}
index={active}
alt={alt}
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
onIndexChange={setActive}
/>
</div>
);
}
@@ -0,0 +1,194 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import type { ProductComment, ProductDetail } from "@/data/product-detail";
type TabId = "technical" | "comments" | "sizing";
const TABS: Array<{ id: TabId; labelFa: string; labelEn: string }> = [
{ id: "technical", labelFa: "مشخصات فنی", labelEn: "Technical info" },
{ id: "comments", labelFa: "نظرات", labelEn: "Comments" },
{ id: "sizing", labelFa: "راهنمای سایز", labelEn: "Sizing guide" },
];
type ProductTabsProps = {
product: ProductDetail;
initialTab?: TabId;
};
function Stars({ rating }: { rating: number }) {
return (
<span dir="ltr" className="font-montserrat text-xs tracking-widest text-ink" aria-label={`${rating} از ۵`}>
{"★".repeat(Math.max(0, Math.min(5, rating)))}
<span className="text-muted">{"★".repeat(Math.max(0, 5 - rating))}</span>
</span>
);
}
function CommentsList({ comments }: { comments: ProductComment[] }) {
if (!comments.length) {
return (
<p className="text-sm text-muted">هنوز نظری ثبت نشده است.</p>
);
}
return (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="border border-line bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-ink">{c.author}</p>
<Stars rating={c.rating} />
</div>
<p dir="ltr" className="mt-1 font-montserrat text-[10px] text-muted">
{c.date}
</p>
<p className="mt-3 text-sm leading-7 text-ink/80">{c.body}</p>
</li>
))}
</ul>
);
}
export default function ProductTabs({
product,
initialTab = "technical",
}: ProductTabsProps) {
const [tab, setTab] = useState<TabId>(initialTab);
useEffect(() => {
const onHash = () => {
if (window.location.hash === "#product-tabs") {
setTab("sizing");
}
};
onHash();
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
return (
<section id="product-tabs" className="scroll-mt-28 border-t border-line pt-10">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`-mb-px border-b-2 px-3 py-3 text-start transition-colors sm:px-4 ${
active
? "border-ink text-ink"
: "border-transparent text-muted hover:text-ink"
}`}
aria-selected={active}
role="tab"
>
<span className="block text-sm sm:text-base">{t.labelFa}</span>
<span
dir="ltr"
className="mt-0.5 block font-montserrat text-[10px] tracking-[0.12em] uppercase opacity-70"
>
{t.labelEn}
</span>
</button>
);
})}
</div>
<div className="py-8" role="tabpanel">
{tab === "technical" ? (
<div className="space-y-6">
<p className="max-w-3xl text-sm leading-7 text-ink/75">
{product.description}
</p>
<dl className="divide-y divide-line border border-line">
{product.technical.map((row) => (
<div
key={row.labelEn}
className="grid grid-cols-1 gap-1 px-4 py-3 sm:grid-cols-[minmax(0,11rem)_1fr] sm:items-baseline sm:gap-6"
>
<dt>
<span className="text-sm text-ink">{row.labelFa}</span>
<span
dir="ltr"
className="mt-0.5 block font-montserrat text-[10px] tracking-wide text-muted"
>
{row.labelEn}
</span>
</dt>
<dd className="text-sm text-ink/80 sm:text-end">{row.value}</dd>
</div>
))}
</dl>
</div>
) : null}
{tab === "comments" ? (
<CommentsList comments={product.comments} />
) : null}
{tab === "sizing" ? (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:items-start">
<div className="relative aspect-[4/5] overflow-hidden rounded-[5px] border border-line bg-[#f6f6f4]">
{product.sizeGuideImage ? (
<Image
src={product.sizeGuideImage}
alt="راهنمای سایز"
fill
sizes="(max-width: 1024px) 100vw, 520px"
className="object-cover object-top"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-sm text-muted">
Size guide image
</span>
)}
</div>
<div>
<h3 className="font-doran text-xl text-ink">جدول اندازه‌ها</h3>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Measurements (cm)
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
{product.sizeGuideNote}
</p>
<div className="mt-5 overflow-x-auto border border-line">
<table className="w-full min-w-[320px] text-sm">
<thead className="bg-[#f7f7f5] text-muted">
<tr>
<th className="px-3 py-2.5 text-start font-normal">سایز</th>
<th className="px-3 py-2.5 text-start font-normal">سینه</th>
<th className="px-3 py-2.5 text-start font-normal">کمر</th>
<th className="px-3 py-2.5 text-start font-normal">قد</th>
</tr>
</thead>
<tbody>
{[
["S", "۸۸", "۷۰", "۹۲"],
["M", "۹۲", "۷۴", "۹۵"],
["L", "۹۶", "۷۸", "۹۸"],
["XL", "۱۰۰", "۸۲", "۱۰۰"],
].map((row) => (
<tr key={row[0]} className="border-t border-line">
{row.map((cell) => (
<td key={cell} className="px-3 py-2.5 text-ink">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "@/components/home/ProductCard";
type SimilarProductsProps = {
products: Product[];
};
export default function SimilarProducts({ products }: SimilarProductsProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const offsetRef = useRef(0);
const maxRef = useRef(0);
const stepRef = useRef(300);
const [offset, setOffset] = useState(0);
const [maxOffset, setMaxOffset] = useState(0);
const applyOffset = useCallback((value: number) => {
const next = Math.min(maxRef.current, Math.max(0, value));
offsetRef.current = next;
setOffset(next);
}, []);
const measure = useCallback(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const card = track.querySelector<HTMLElement>("[data-similar-card]");
const styles = getComputedStyle(track);
const gap = parseFloat(styles.columnGap || styles.gap || "12") || 12;
const cardW = card?.offsetWidth ?? 280;
stepRef.current = cardW + gap;
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
setMaxOffset(maxRef.current);
applyOffset(offsetRef.current);
}, [applyOffset]);
useEffect(() => {
measure();
const viewport = viewportRef.current;
if (!viewport) return;
const ro = new ResizeObserver(() => measure());
ro.observe(viewport);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
const t = window.setTimeout(measure, 200);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products]);
if (!products.length) return null;
const canPrev = offset > 0;
const canNext = offset < maxOffset - 1;
return (
<section dir="rtl" className="border-t border-line pt-10">
<div className="mb-6 flex items-end justify-between gap-4">
<div className="min-w-0 text-right">
<h2 className="font-doran text-2xl font-medium text-ink sm:text-3xl">
محصولات مشابه
</h2>
<p
dir="ltr"
className="mt-1 w-full text-right font-montserrat text-[11px] font-light uppercase tracking-[0.18em] text-muted"
>
Similar products
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
aria-label="قبلی"
disabled={!canPrev}
onClick={() => applyOffset(offsetRef.current - stepRef.current)}
className="flex size-10 items-center justify-center border border-line bg-white transition-colors hover:border-ink disabled:pointer-events-none disabled:opacity-30"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={18}
height={18}
unoptimized
/>
</button>
<button
type="button"
aria-label="بعدی"
disabled={!canNext}
onClick={() => applyOffset(offsetRef.current + stepRef.current)}
className="flex size-10 items-center justify-center border border-line bg-white transition-colors hover:border-ink disabled:pointer-events-none disabled:opacity-30"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={18}
height={18}
unoptimized
/>
</button>
</div>
</div>
<div ref={viewportRef} className="relative overflow-hidden">
<div
ref={trackRef}
className="flex gap-3 transition-transform duration-300 ease-out will-change-transform"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
>
{products.map((product) => (
<div
key={product.id}
data-similar-card
className="w-[240px] shrink-0 sm:w-[260px] lg:w-[280px]"
>
<ProductCard product={product} className="max-w-none" />
</div>
))}
</div>
</div>
</section>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import Image from "next/image";
import { useState } from "react";
type ActiveFilter = {
id: string;
label: string;
value: string;
};
const INITIAL_FILTERS: ActiveFilter[] = [
{ id: "cat", label: "دسته بندی", value: "شلوار جین" },
{ id: "min", label: "حداقل قیمت", value: "۳.۰۰۰.۰۰۰ تومان" },
];
function FilterField({
label,
value,
onChange,
placeholder,
iconSrc,
className = "",
inputMode,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
iconSrc: string;
className?: string;
inputMode?: "text" | "numeric";
}) {
return (
<label className={`relative block w-full ${className}`}>
<span className="sr-only">{label}</span>
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
<Image src={iconSrc} alt="" width={16} height={16} unoptimized className="opacity-70" />
</span>
<input
type="text"
inputMode={inputMode}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-9 w-full rounded-[5px] border border-[#818181]/40 bg-white pe-3 ps-9 text-right text-sm font-light text-ink placeholder:text-[#818181] outline-none focus:border-ink"
/>
</label>
);
}
export default function ProductFilters() {
const [name, setName] = useState("");
const [minPrice, setMinPrice] = useState("");
const [maxPrice, setMaxPrice] = useState("");
const [active, setActive] = useState<ActiveFilter[]>(INITIAL_FILTERS);
const clearAll = () => {
setName("");
setMinPrice("");
setMaxPrice("");
setActive([]);
};
const removeChip = (id: string) => {
setActive((prev) => prev.filter((f) => f.id !== id));
};
return (
<div dir="rtl" className="mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px]">
<div className="flex flex-col gap-3 rounded-[5px] border border-[#818181]/40 px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<p className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
فیلترها
</p>
{/* justify-start packs toward inline-start = right in RTL (next to label) */}
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-start sm:gap-3">
<FilterField
label="نام محصول"
value={name}
onChange={setName}
placeholder="نام محصول"
iconSrc="/images/home/icons/search.svg"
className="sm:max-w-[269px]"
/>
<FilterField
label="حداقل قیمت"
value={minPrice}
onChange={setMinPrice}
placeholder="حداقل قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
<FilterField
label="حداکثر قیمت"
value={maxPrice}
onChange={setMaxPrice}
placeholder="حداکثر قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="بازنشانی فیلترها"
onClick={clearAll}
className="flex size-11 items-center justify-center transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/refresh.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
<button
type="button"
aria-label="اعمال فیلتر"
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
>
<Image
src="/images/home/icons/funnel.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
</div>
{active.length > 0 ? (
<ul className="mt-3 flex flex-wrap items-center justify-start gap-3">
{active.map((chip) => (
<li
key={chip.id}
className="inline-flex h-10 items-center gap-2 rounded-[5px] bg-[#eee] px-3"
>
<button
type="button"
aria-label={`حذف فیلتر ${chip.label}`}
onClick={() => removeChip(chip.id)}
className="shrink-0 transition-opacity hover:opacity-60"
>
<Image
src="/images/home/icons/close.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
<p className="text-sm font-light uppercase">
<span className="text-[#808080]">{chip.label}</span>
<span className="text-[#b4b4b4]">: </span>
<span className="text-ink">{chip.value}</span>
</p>
</li>
))}
</ul>
) : null}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import ProductCard from "@/components/home/ProductCard";
import type { Product } from "@/data/home";
type ProductGridProps = {
products: Product[];
};
export default function ProductGrid({ products }: ProductGridProps) {
return (
<ul
dir="rtl"
className="mx-auto grid w-full max-w-[1920px] grid-cols-2 gap-3 px-4 sm:grid-cols-3 sm:gap-4 sm:px-6 md:grid-cols-4 lg:grid-cols-5 lg:gap-[22px] lg:px-[120px]"
>
{products.map((product) => (
<li key={product.id} className="min-w-0">
<ProductCard product={product} className="max-w-none" />
</li>
))}
</ul>
);
}
@@ -0,0 +1,75 @@
import Image from "next/image";
import Link from "next/link";
import { LIST_PAGE_META } from "@/data/products";
type ProductListHeroProps = {
titleFa?: string;
titleEn?: string;
heroImage?: string;
breadcrumbs?: readonly { label: string; href?: string }[];
};
export default function ProductListHero({
titleFa = LIST_PAGE_META.titleFa,
titleEn = LIST_PAGE_META.titleEn,
heroImage = LIST_PAGE_META.heroImage,
breadcrumbs = LIST_PAGE_META.breadcrumbs,
}: ProductListHeroProps) {
return (
<section className="relative h-[min(52vw,469px)] min-h-[280px] w-full overflow-hidden bg-[#ebe6df]">
<Image
src={heroImage}
alt=""
fill
priority
sizes="100vw"
className="object-cover object-[center_20%]"
/>
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
{/* Same inset as header / filters / grid: px-4 sm:px-6 lg:px-[120px] */}
<div className="absolute inset-x-0 bottom-5 z-10 px-4 sm:bottom-6 sm:px-6 lg:bottom-8 lg:px-[120px]">
<div className="w-full max-w-[420px] sm:max-w-[460px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex h-8 items-center justify-start gap-1.5 bg-ink px-3 text-[11px] font-light text-white sm:h-9 sm:gap-2 sm:px-4 sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="inline-flex items-center gap-1.5 sm:gap-2">
{i > 0 ? (
<Image
src="/images/home/icons/caret-left.svg"
alt=""
width={12}
height={12}
unoptimized
className="brightness-0 invert"
/>
) : null}
{crumb.href ? (
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
<div className="bg-white px-4 pb-2.5 pt-2 text-right sm:px-5 sm:pb-3 sm:pt-2.5">
<h1 className="text-lg font-normal leading-none text-ink sm:text-xl lg:text-[22px]">
{titleFa}
</h1>
<p
dir="ltr"
className="mt-1.5 text-right text-xs font-light leading-none uppercase tracking-[0.3em] text-[#898989] sm:text-sm sm:tracking-[0.35em]"
>
{titleEn}
</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
const PAGES = [1, 2, 3, 4, 5] as const;
const linkClass =
"px-1 py-0.5 transition-colors duration-200 hover:text-ink";
const pageClass =
"flex h-7 w-7 items-center justify-center rounded-[4px] transition-colors duration-200 sm:h-8 sm:w-8";
export default function ProductPagination() {
const [page, setPage] = useState(3);
const total = 12;
return (
<nav
dir="ltr"
aria-label="صفحه‌بندی محصولات"
className="mx-auto flex max-w-[1920px] flex-wrap items-center justify-center gap-x-3 gap-y-2 px-4 pt-2 pb-6 font-montserrat text-sm text-[#6a6a6a] sm:gap-x-4 sm:text-[15px] lg:pt-3 lg:pb-8"
>
<button type="button" onClick={() => setPage(1)} className={linkClass}>
FIRST
</button>
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={linkClass}
>
PREV
</button>
{PAGES.map((n) => (
<button
key={n}
type="button"
aria-current={page === n ? "page" : undefined}
onClick={() => setPage(n)}
className={
page === n
? `${pageClass} bg-ink text-white`
: `${pageClass} text-ink hover:bg-ink hover:text-white`
}
>
{n}
</button>
))}
<span className="text-ink" aria-hidden>
...
</span>
<button
type="button"
onClick={() => setPage(total)}
className={`${pageClass} text-ink hover:bg-ink hover:text-white`}
>
{total}
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(total, p + 1))}
className={linkClass}
>
NEXT
</button>
<button type="button" onClick={() => setPage(total)} className={linkClass}>
LAST
</button>
</nav>
);
}
+355
View File
@@ -0,0 +1,355 @@
import { categoryPath } from "@/lib/category-path";
export type Product = {
id: string | number;
nameFa: string;
nameEn: string;
price: string;
image: string;
/** Second photo revealed on card hover (slides in from the left). */
imageHover?: string;
href?: string;
};
export type Category = {
id: string;
nameFa: string;
nameEn: string;
image: string;
href?: string;
};
export type Feature = {
id: string;
title: string;
subtitle: string;
icon: string;
};
export const NAV_LINKS = [
{ label: "محصولات", href: "/products", hasDropdown: true },
{ label: "جدیدترین ها", href: "/products?sort=newest" },
{ label: "فروش ویژه", href: "/products?sale=1" },
{ label: "تماس با ما", href: "/contact" },
{ label: "درباره ما", href: "/about" },
] as const;
export const MEGA_MENU_TABS = [
{
id: "coat",
nameFa: "پالتو و کت",
nameEn: "Coats",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
image: "/images/home/products/coat-blue.jpg",
links: [
{
label: "پالتو",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت و شلوار",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت و دامن",
href: categoryPath({
id: "skirts",
nameEn: "Skirts",
nameFa: "دامن",
}),
},
],
},
{
id: "shirts",
nameFa: "پیراهن",
nameEn: "Shirts",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
image: "/images/home/products/coat-navy.jpg",
links: [
{
label: "پیراهن",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
label: "شومیز",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
label: "تی شرت",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
],
},
{
id: "pants",
nameFa: "شلوار",
nameEn: "Pants",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
image: "/images/home/products/coat-check.jpg",
links: [
{
label: "شلوار",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
{
label: "شلوار جین",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
],
},
{
id: "skirts",
nameFa: "دامن",
nameEn: "Skirts",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
image: "/images/home/products/coat-beige.jpg",
links: [
{
label: "دامن",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
{
label: "کت و دامن",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
],
},
{
id: "bags",
nameFa: "کیف",
nameEn: "Bags",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
image: "/images/home/banners/hero.jpg",
links: [
{
label: "کیف دستی",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
{
label: "کوله",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
],
},
{
id: "leather",
nameFa: "چرم",
nameEn: "Leather",
href: "/products?collection=leather",
image: "/images/home/banners/leather.png",
links: [
{ label: "کالکشن چرم", href: "/products?collection=leather" },
{
label: "کیف چرم",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
],
},
] as const;
export const ABOUT = {
titleFa: "درباره ما",
titleEn: "About Us",
lead: "مانا کالکشن برای بانویی که خودش انتخاب می‌کند.",
body: "ما با تمرکز روی کیفیت، دوخت و جزئیات، کالکشن‌هایی می‌سازیم که هم معاصر باشند و هم ماندگار. از چرم تا پالتوهای فصل، هر قطعه با دقت انتخاب و ارائه می‌شود تا تجربه خرید ساده، شفاف و لذت‌بخش باشد.",
image: "/images/home/banners/hero.jpg",
imageSecondary: "/images/home/products/coat-beige.jpg",
} as const;
export const CATEGORIES: Category[] = [
{
id: "shirts",
nameFa: "پیراهن",
nameEn: "Shirts",
image: "/images/home/categories/cat-6.png",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
id: "coat",
nameFa: "کت",
nameEn: "Coat",
image: "/images/home/categories/cat-2.png",
href: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" }),
},
{
id: "sunglasses",
nameFa: "عینک",
nameEn: "Glasses",
image: "/images/home/categories/cat-4.png",
href: categoryPath({
id: "sunglasses",
nameEn: "Glasses",
nameFa: "عینک",
}),
},
{
id: "bags",
nameFa: "کیف",
nameEn: "Bags",
image: "/images/home/categories/cat-3.png",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
{
id: "pants",
nameFa: "شلوار",
nameEn: "Pants",
image: "/images/home/categories/cat-1.png",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
{
id: "skirts",
nameFa: "دامن",
nameEn: "Skirts",
image: "/images/home/categories/cat-5.png",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
];
export const SALE_PRODUCTS: Product[] = [
{
id: "p1",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-beige.jpg",
},
{
id: "p2",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
},
{
id: "p3",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-navy.jpg",
imageHover: "/images/home/products/coat-check.jpg",
},
{
id: "p4",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-check.jpg",
imageHover: "/images/home/products/coat-blue.jpg",
},
{
id: "p5",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
},
{
id: "p6",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-check.jpg",
},
];
export const AUTUMN_PRODUCTS: Product[] = SALE_PRODUCTS;
export const FEATURES: Feature[] = [
{
id: "support",
title: "پشتیبانی آنلاین",
subtitle: "شنبه تا پنج شنبه | 10:00 الی 20:00",
icon: "/images/home/features/support.svg",
},
{
id: "return",
title: "تعویض و مرجوعی",
subtitle: "تا هفت روز پس از دریافت",
icon: "/images/home/features/return.svg",
},
{
id: "authentic",
title: "ضمانت اصالت کالا",
subtitle: "تضمین کیفیت و اصالت",
icon: "/images/home/features/authentic.svg",
},
{
id: "shipping",
title: "ارسال رایگان",
subtitle: "برای خرید های بالای 4 میلیون",
icon: "/images/home/features/shipping.svg",
},
];
export const BLACK_ITEMS = [
{
id: "coat",
image: "/images/home/black/coat.png",
alt: "پالتو مشکی",
nameFa: "پالتو",
nameEn: "Coat",
},
{
id: "pants",
image: "/images/home/black/pants.png",
alt: "شلوار مشکی",
nameFa: "شلوار",
nameEn: "Pants",
},
{
id: "shirt",
image: "/images/home/black/shirt.png",
alt: "پیراهن مشکی",
nameFa: "پیراهن",
nameEn: "Shirt",
},
{
id: "bag",
image: "/images/home/black/bag.png",
alt: "کیف مشکی",
nameFa: "کیف",
nameEn: "Bag",
},
] as const;
export const FOOTER_STORE_LINKS = [
{ label: "کالکشن پاییزه", href: "/products?collection=autumn" },
{ label: "فروش ویژه", href: "/products?sale=1" },
{ label: "پر فروش ترین ها", href: "/products?sort=popular" },
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
] as const;
export const FOOTER_CATEGORIES_COL1 = [
"پالتو",
"پیراهن",
"شومیز",
"تی شرت",
"کت و دامن",
"کت و شلوار",
] as const;
export const FOOTER_CATEGORIES_COL2 = [
"کیف",
"عینک",
"جوراب",
"تی شرت",
"کت و دامن",
"کت و شلوار",
] as const;
export const SOCIAL_LINKS = [
{ id: "instagram", href: "#", icon: "/images/home/social/instagram.svg", label: "اینستاگرام" },
{ id: "telegram", href: "#", icon: "/images/home/social/telegram.svg", label: "تلگرام" },
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
{ id: "linkedin", href: "#", icon: "/images/home/social/linkedin.svg", label: "لینکدین" },
] as const;
+99
View File
@@ -0,0 +1,99 @@
import { LIST_PAGE_META } from "./products";
import { ABOUT, CATEGORIES, type Category } from "./home";
export const ABOUT_PAGE = {
titleFa: ABOUT.titleFa,
titleEn: ABOUT.titleEn,
heroImage: LIST_PAGE_META.heroImage,
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "درباره ما" },
],
lead: ABOUT.lead,
body: ABOUT.body,
image: ABOUT.image,
imageSecondary: ABOUT.imageSecondary,
storyTitleFa: "داستان ما",
storyTitleEn: "Our story",
story:
"مانا کالکشن از علاقه به لباس‌هایی شکل گرفت که هم زیبا باشند و هم واقعاً پوشیده شوند. ما با تولیدکننده‌ها و طراحانی کار می‌کنیم که به جزئیات، جنس پارچه و دوام اهمیت می‌دهند — تا هر انتخاب شما، انتخابی مطمئن باشد.",
values: [
{
titleFa: "کیفیت ساخت",
titleEn: "Craft",
body: "انتخاب پارچه، دوخت و فیتینگ با استانداردی که برای استفاده روزمره و ماندگاری طراحی شده است.",
},
{
titleFa: "انتخاب آگاهانه",
titleEn: "Choice",
body: "اطلاعات شفاف درباره سایز، جنس و مراقبت — تا خرید، بدون حدس و پشیمانی باشد.",
},
{
titleFa: "تجربه ساده",
titleEn: "Experience",
body: "از مرور کالکشن تا پشتیبانی پس از خرید، مسیر را کوتاه، روشن و قابل اعتماد نگه می‌داریم.",
},
],
} as const;
export const CONTACT_PAGE = {
titleFa: "تماس با ما",
titleEn: "Contact Us",
heroImage: LIST_PAGE_META.heroImage,
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "تماس با ما" },
],
lead: "سوال، پیشنهاد یا پیگیری سفارش؟ پیام بگذارید — تیم مانا پاسخ می‌دهد.",
address: "تهران، خیابان ولیعصر بالاتر از پارک ساعی، پلاک ۲۴۸",
phoneStore: "021-22423600",
phoneMobile: "09122504800",
email: "Info@ManaCollecitions.com",
hours: "شنبه تا پنج‌شنبه | ۱۰:۰۰ الی ۲۰:۰۰",
} as const;
export type CategoryPageMeta = Category & {
description: string;
heroImage: string;
};
const CATEGORY_EXTRA: Record<
string,
{ description: string; heroImage?: string }
> = {
shirts: {
description: "پیراهن‌ها و شومیزهایی برای روزمره تا استایل رسمی‌تر.",
},
coat: {
description: "کت و پالتوهای فصل با برش‌های تمیز و ایستایی دقیق.",
heroImage: "/images/home/products/coat-blue.jpg",
},
sunglasses: {
description: "اکسسوری‌هایی که استایل را کامل می‌کنند.",
},
bags: {
description: "کیف‌های روزمره و رسمی با تمرکز روی جنس و کاربرد.",
},
pants: {
description: "شلوار و جین با فیت‌های متنوع برای استایل روزمره.",
heroImage: "/images/home/products/coat-check.jpg",
},
skirts: {
description: "دامن‌هایی برای ترکیب با کت، شومیز یا بافت فصل.",
},
};
export function getCategoryPage(slug: string): CategoryPageMeta | null {
const base = CATEGORIES.find((c) => c.id === slug);
if (!base) return null;
const extra = CATEGORY_EXTRA[slug];
return {
...base,
description:
extra?.description ||
`مجموعه‌ای از محصولات ${base.nameFa} در مانا کالکشن.`,
heroImage: extra?.heroImage || LIST_PAGE_META.heroImage,
};
}
export const CATEGORY_SLUGS = CATEGORIES.map((c) => c.id);
+162
View File
@@ -0,0 +1,162 @@
import { SALE_PRODUCTS, type Product } from "./home";
import { productPath } from "@/lib/product-path";
export type ProductVariantOption = {
id: string;
labelFa: string;
labelEn: string;
/** Hex or image for color chips */
swatch?: string;
available?: boolean;
};
export type ProductComment = {
id: string;
author: string;
date: string;
rating: number;
body: string;
};
export type ProductDetail = Product & {
summary: string;
description: string;
gallery: string[];
colors: ProductVariantOption[];
sizes: ProductVariantOption[];
packages: ProductVariantOption[];
technical: Array<{ labelFa: string; labelEn: string; value: string }>;
comments: ProductComment[];
sizeGuideImage: string | null;
sizeGuideNote: string;
breadcrumbs: Array<{ label: string; href?: string }>;
};
const GALLERY = [
"/images/home/products/coat-blue.jpg",
"/images/home/products/coat-beige.jpg",
"/images/home/products/coat-navy.jpg",
"/images/home/products/coat-check.jpg",
];
const COLORS: ProductVariantOption[] = [
{ id: "blue", labelFa: "آبی", labelEn: "Blue", swatch: "#2f4a6e" },
{ id: "beige", labelFa: "بژ", labelEn: "Beige", swatch: "#c4b09a" },
{ id: "navy", labelFa: "سرمه‌ای", labelEn: "Navy", swatch: "#1a2744" },
{
id: "check",
labelFa: "چهارخانه",
labelEn: "Check",
swatch: "#6b6358",
available: false,
},
];
const SIZES: ProductVariantOption[] = [
{ id: "s", labelFa: "S", labelEn: "S" },
{ id: "m", labelFa: "M", labelEn: "M" },
{ id: "l", labelFa: "L", labelEn: "L" },
{ id: "xl", labelFa: "XL", labelEn: "XL" },
];
const PACKAGES: ProductVariantOption[] = [
{ id: "std", labelFa: "استاندارد", labelEn: "Standard" },
{ id: "gift", labelFa: "کادویی", labelEn: "Gift wrap" },
];
const TECHNICAL = [
{ labelFa: "جنس", labelEn: "Fabric", value: "فوتر پشمی با آستر ساتن" },
{ labelFa: "قد", labelEn: "Length", value: "۹۵ سانتی‌متر (سایز M)" },
{ labelFa: "یقه", labelEn: "Collar", value: "یقه ایستاده" },
{ labelFa: "بسته‌شدن", labelEn: "Closure", value: "دکمه مخفی + کمربند" },
{ labelFa: "مراقبت", labelEn: "Care", value: "خشکشویی تخصصی" },
{ labelFa: "کشور تولید", labelEn: "Origin", value: "ایران" },
];
const COMMENTS: ProductComment[] = [
{
id: "c1",
author: "سارا م.",
date: "۱۴۰۴/۰۶/۱۲",
rating: 5,
body: "کیفیت دوخت عالی بود و رنگ دقیقاً همان چیزی بود که در سایت دیدم. سایز M برای قد ۱۶۵ مناسب است.",
},
{
id: "c2",
author: "نیکا ر.",
date: "۱۴۰۴/۰۵/۲۸",
rating: 4,
body: "پارچه گرم و لطیف است. فقط کمی دیر به دستم رسید ولی پشتیبانی پاسخگو بود.",
},
{
id: "c3",
author: "آیدا ک.",
date: "۱۴۰۴/۰۴/۰۳",
rating: 5,
body: "برای فصل پاییز عالیه. راهنمای سایز کمک کرد انتخاب درستی داشته باشم.",
},
];
function buildDetail(base: Product, index: number): ProductDetail {
const gallery = [
base.image,
base.imageHover ?? GALLERY[(index + 1) % GALLERY.length],
GALLERY[(index + 2) % GALLERY.length],
GALLERY[(index + 3) % GALLERY.length],
].filter((src, i, arr) => arr.indexOf(src) === i);
return {
...base,
href: productPath(base),
summary:
"پالتوی فوتر با برش کلاسیک و جزئیات دقیق — مناسب روزهای خنک پاییز و زمستان.",
description:
"این پالتو با تمرکز روی کیفیت دوخت و ایستایی روی اندام طراحی شده است. آستر داخلی نرم، دکمه‌های مخفی و کمربند جداشدنی به شما آزادی استایل می‌دهد. با شلوار جین، دامن میدی یا ست رسمی به‌راحتی ست می‌شود.",
gallery,
colors: COLORS,
sizes: SIZES,
packages: PACKAGES,
technical: TECHNICAL,
comments: COMMENTS,
sizeGuideImage: "/images/home/banners/leather.png",
sizeGuideNote:
"اندازه‌ها بر حسب سانتی‌متر است. اگر بین دو سایز هستید، سایز بزرگ‌تر را انتخاب کنید.",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "پالتو و کت", href: "/products?cat=coat" },
{ label: base.nameFa },
],
};
}
export const PRODUCT_DETAILS: Record<string, ProductDetail> = Object.fromEntries(
SALE_PRODUCTS.map((p, i) => [p.id, buildDetail(p, i)]),
);
export function getProductDetail(id: string): ProductDetail | null {
if (PRODUCT_DETAILS[id]) return PRODUCT_DETAILS[id];
// list-* ids from products page map back to base sale products
const listMatch = /^list-(\d+)$/.exec(id);
if (listMatch) {
const n = Number(listMatch[1]) - 1;
const base = SALE_PRODUCTS[n % SALE_PRODUCTS.length];
return buildDetail({ ...base, id }, n);
}
return null;
}
export function getSimilarProducts(id: string, limit = 8): Product[] {
const others = SALE_PRODUCTS.filter((p) => p.id !== id);
const pool = others.length ? others : SALE_PRODUCTS;
return Array.from({ length: limit }, (_, i) => {
const base = pool[i % pool.length];
return {
...base,
id: `similar-${id}-${i}`,
href: productPath(base),
};
});
}
export const PRODUCT_DETAIL_IDS = Object.keys(PRODUCT_DETAILS);
+25
View File
@@ -0,0 +1,25 @@
import { SALE_PRODUCTS, type Product } from "./home";
import { productPath } from "@/lib/product-path";
export type { Product };
/** Expand home products into a denser list for the products page grid. */
export const LIST_PRODUCTS: Product[] = Array.from({ length: 15 }, (_, i) => {
const base = SALE_PRODUCTS[i % SALE_PRODUCTS.length];
return {
...base,
id: `list-${i + 1}`,
href: productPath(base),
};
});
export const LIST_PAGE_META = {
titleFa: "شلوار جین",
titleEn: "Jean Pants",
heroImage: "/images/home/banners/hero.jpg",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "لیست محصولات", href: "/products" },
{ label: "دسته بندی شلوار جین", href: "/products?cat=jeans" },
],
} as const;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
import type { Category } from "@/data/home";
import { CATEGORIES, MEGA_MENU_TABS } from "@/data/home";
import type { MegaTabData } from "@/components/home/ProductsMegaMenu";
import { categoryPath } from "@/lib/category-path";
import { publicFetch, STORE_SLUG } from "@/lib/public-api";
export type ApiCategoryNode = {
id: number;
parentId: number | null;
nameFa: string;
nameEn: string;
sortOrder: number;
imageUrl: string | null;
children: ApiCategoryNode[];
};
const FALLBACK_CAT_IMAGE = "/images/home/categories/cat-1.png";
export async function fetchPublicCategories(): Promise<ApiCategoryNode[]> {
const data = await publicFetch<{
storeSlug: string;
categories: ApiCategoryNode[];
}>(`/public/categories?storeSlug=${encodeURIComponent(STORE_SLUG)}`);
return data?.categories ?? [];
}
export function mapCategoriesToStrip(
nodes: ApiCategoryNode[],
): Category[] {
if (!nodes.length) return CATEGORIES;
return nodes.map((node) => ({
id: String(node.id),
nameFa: node.nameFa,
nameEn: node.nameEn || node.nameFa,
image: node.imageUrl || FALLBACK_CAT_IMAGE,
href: categoryPath(node),
}));
}
export function mapCategoriesToMegaTabs(
nodes: ApiCategoryNode[],
slotImages?: Array<{ image: string; nameFa?: string; href?: string }>,
): MegaTabData[] {
if (!nodes.length) return [...MEGA_MENU_TABS];
return nodes.map((node, index) => {
const slot = slotImages?.[index];
const children = node.children ?? [];
const columns =
children.length > 0
? children.map((child) => {
const grand = child.children ?? [];
return {
title: child.nameFa,
href: categoryPath(child),
links:
grand.length > 0
? grand.map((g) => ({
label: g.nameFa,
href: categoryPath(g),
}))
: [],
};
})
: [
{
title: node.nameFa,
href: categoryPath(node),
links: [],
},
];
// Flat links kept for any consumer still reading them
const links = columns.flatMap((col) =>
col.links.length
? col.links
: [{ label: col.title, href: col.href }],
);
return {
id: String(node.id),
nameFa: node.nameFa,
nameEn: node.nameEn || node.nameFa,
href: categoryPath(node),
image: slot?.image || node.imageUrl || FALLBACK_CAT_IMAGE,
columns,
links,
};
});
}
+29
View File
@@ -0,0 +1,29 @@
/** Latin slug for category URLs. Falls back to `id` when names have no Latin chars. */
export function slugifyCategory(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export type CategoryPathInput = {
id: string | number;
nameEn?: string | null;
nameFa?: string | null;
};
export function categorySlug(input: CategoryPathInput): string {
return (
slugifyCategory(input.nameEn ?? "") ||
slugifyCategory(input.nameFa ?? "") ||
String(input.id)
);
}
/** Canonical category URL: `/categories/{id}/{slug}` */
export function categoryPath(input: CategoryPathInput): string {
return `/categories/${input.id}/${categorySlug(input)}`;
}
+301
View File
@@ -0,0 +1,301 @@
import type { Category } from "@/data/home";
import {
ABOUT,
BLACK_ITEMS,
CATEGORIES,
MEGA_MENU_TABS,
} from "@/data/home";
import type { HeroSlideData } from "@/components/home/HeroSlider";
import { categoryPath } from "@/lib/category-path";
import type { PublicWebsiteSlotItem } from "@/lib/website-slots";
import { firstForSlot, itemsForSlot } from "@/lib/website-slots";
export type HomeSlotContent = {
heroSlides: HeroSlideData[];
categories: Category[];
leather: {
image: string;
nameFa: string;
nameEn: string;
subtext: string;
linkText: string;
link: string;
};
promoSale: {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
} | null;
promoDiscount: {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
} | null;
promoAutumn: {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
} | null;
blackItems: Array<{
id: string;
image: string;
alt: string;
nameFa: string;
nameEn: string;
href: string;
}>;
about: {
titleFa: string;
titleEn: string;
lead: string;
body: string;
image: string;
imageSecondary: string;
};
paymentBadges: string;
megaTabs: Array<{
id: string;
nameFa: string;
nameEn: string;
href: string;
image: string;
links: readonly { label: string; href: string }[];
columns?: readonly {
title: string;
href: string;
links: readonly { label: string; href: string }[];
}[];
}>;
};
function slotItems(
items: PublicWebsiteSlotItem[],
slug: string,
...legacy: string[]
) {
const modern = itemsForSlot(items, slug);
if (modern.length) return modern;
for (const key of legacy) {
const found = itemsForSlot(items, key);
if (found.length) return found;
}
return [];
}
function firstSlot(
items: PublicWebsiteSlotItem[],
slug: string,
...legacy: string[]
) {
return slotItems(items, slug, ...legacy)[0] ?? null;
}
export function mapHomeSlots(
items: PublicWebsiteSlotItem[],
): HomeSlotContent {
const heroItems = slotItems(items, "hero-slider", "home.hero");
const heroSlides: HeroSlideData[] =
heroItems.length > 0
? heroItems.map((item, i) => ({
id: item.id,
image: item.imageUrl || "/images/home/banners/hero.jpg",
imagePosition:
i === 0
? "object-[center_20%]"
: i === 1
? "object-[center_40%]"
: "object-[center_55%]",
title: item.nameFa || item.nameEn || "مانا کالکشن",
subtitle: item.nameEn || item.subtext || "",
cta: {
label: item.linkText || "مشاهده",
href: item.link || "/products",
},
}))
: [];
const categoryItems = slotItems(items, "category-strip");
const categories: Category[] =
categoryItems.length > 0
? categoryItems.map((item, index) => {
const fallback = CATEGORIES[index % CATEGORIES.length];
return {
id: item.id || `cat-${index}`,
nameFa: item.nameFa || `دسته ${index + 1}`,
nameEn: item.nameEn || "",
image: item.imageUrl || fallback.image,
href:
item.link ||
(fallback
? categoryPath(fallback)
: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" })),
};
})
: CATEGORIES.map((cat) => {
const legacy = firstForSlot(items, `home.category.${cat.id}`);
if (!legacy) return cat;
return {
...cat,
nameFa: legacy.nameFa || cat.nameFa,
nameEn: legacy.nameEn || cat.nameEn,
image: legacy.imageUrl || cat.image,
href: legacy.link || cat.href || categoryPath(cat),
};
});
const leatherSlot = firstSlot(
items,
"leather-banner",
"home.banner.leather",
);
const leather = {
image: leatherSlot?.imageUrl || "/images/home/banners/leather.png",
nameFa: leatherSlot?.nameFa || "هنر پوشیدن",
nameEn: leatherSlot?.nameEn || "THE ART OF DRESSING",
subtext: leatherSlot?.subtext || "",
linkText: leatherSlot?.linkText || "کالکشن چرم",
link: leatherSlot?.link || "/products?collection=leather",
};
const saleSlot = firstSlot(
items,
"promo-1",
"promo-sale",
"home.promo.sale",
);
const promoSale = saleSlot
? {
image: saleSlot.imageUrl || undefined,
nameEn: saleSlot.nameEn || "",
nameFa: saleSlot.nameFa || "",
subtext: saleSlot.subtext || "",
link: saleSlot.link || "/products?sale=1",
}
: null;
const discountSlot = firstSlot(items, "promo-2", "promo-discount");
const promoDiscount = discountSlot
? {
image: discountSlot.imageUrl || undefined,
nameFa: discountSlot.nameFa || discountSlot.linkText || "",
nameEn: discountSlot.nameEn || "",
subtext: discountSlot.subtext || "",
link: discountSlot.link || "/products?sale=1",
}
: null;
const autumnSlot = firstSlot(
items,
"promo-3",
"promo-autumn",
"home.promo.autumn",
);
const promoAutumn = autumnSlot
? {
image: autumnSlot.imageUrl || undefined,
nameEn: autumnSlot.nameEn || "",
nameFa: autumnSlot.nameFa || autumnSlot.linkText || "",
subtext: autumnSlot.subtext || "",
link: autumnSlot.link || "/products?collection=autumn",
}
: null;
const portraitItems = slotItems(items, "four-portrait-banner");
const blackItems =
portraitItems.length > 0
? Array.from({ length: 4 }).map((_, index) => {
const slot =
portraitItems.find((row) => row.sortOrder === index) ??
portraitItems[index];
const fallback = BLACK_ITEMS[index] ?? BLACK_ITEMS[0];
return {
id: slot?.id || fallback.id,
image: slot?.imageUrl || fallback.image,
alt: slot?.nameFa || fallback.alt,
nameFa: slot?.nameFa || fallback.nameFa,
nameEn: slot?.nameEn || fallback.nameEn,
href:
slot?.link ||
`/products?color=black&item=${fallback.id}`,
};
})
: BLACK_ITEMS.map((item) => {
const slot = firstForSlot(items, `home.black.${item.id}`);
return {
id: item.id,
image: slot?.imageUrl || item.image,
alt: slot?.nameFa || item.alt,
nameFa: slot?.nameFa || item.nameFa,
nameEn: slot?.nameEn || item.nameEn,
href: slot?.link || `/products?color=black&item=${item.id}`,
};
});
const aboutItems = slotItems(items, "about");
const aboutPrimary =
aboutItems.find((row) => row.sortOrder === 0) ??
aboutItems[0] ??
firstSlot(items, "about-primary", "home.about.primary");
const aboutSecondary =
aboutItems.find((row) => row.sortOrder === 1) ??
aboutItems[1] ??
firstSlot(items, "about-secondary", "home.about.secondary");
const about = {
titleFa: aboutPrimary?.nameFa || ABOUT.titleFa,
titleEn: aboutPrimary?.nameEn || ABOUT.titleEn,
lead: aboutPrimary?.subtext || ABOUT.lead,
body: ABOUT.body,
image: aboutPrimary?.imageUrl || ABOUT.image,
imageSecondary: aboutSecondary?.imageUrl || ABOUT.imageSecondary,
};
const paymentSlot = firstSlot(
items,
"payment-badges",
"home.footer.payment_badges",
);
const paymentBadges =
paymentSlot?.imageUrl || "/images/home/banners/payment-badges.png";
const megaItems = slotItems(items, "mega-menu");
const megaTabs = MEGA_MENU_TABS.map((tab, index) => {
const slot =
megaItems.find((row) => row.sortOrder === index) ??
megaItems[index] ??
firstForSlot(items, `nav.mega.${tab.id}`);
return {
id: tab.id,
nameFa: slot?.nameFa || tab.nameFa,
nameEn: slot?.nameEn || tab.nameEn,
href: slot?.link || tab.href,
image: slot?.imageUrl || tab.image,
links: tab.links,
columns: [
{
title: slot?.nameFa || tab.nameFa,
href: slot?.link || tab.href,
links: tab.links,
},
],
};
});
return {
heroSlides,
categories,
leather,
promoSale,
promoDiscount,
promoAutumn,
blackItems,
about,
paymentBadges,
megaTabs,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { slugifyCategory } from "@/lib/category-path";
export type ProductPathInput = {
id: string | number;
nameEn?: string | null;
nameFa?: string | null;
};
export function productSlug(input: ProductPathInput): string {
return (
slugifyCategory(input.nameEn ?? "") ||
slugifyCategory(input.nameFa ?? "") ||
String(input.id)
);
}
/** Canonical product URL: `/products/{id}/{slug}` */
export function productPath(input: ProductPathInput): string {
return `/products/${input.id}/${productSlug(input)}`;
}
+339
View File
@@ -0,0 +1,339 @@
import type { Product } from "@/data/home";
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
import { SALE_PRODUCTS } from "@/data/home";
import { categoryPath } from "@/lib/category-path";
import { productPath } from "@/lib/product-path";
import { publicFetch, STORE_SLUG } from "@/lib/public-api";
export type ApiProduct = {
id: number;
nameFa: string;
nameEn: string;
summary?: string;
description?: string;
price: number;
discountPrice?: number | null;
imageUrl?: string | null;
galleryUrls?: string[];
tags?: string[];
featuredSlot?: string | null;
technicalData?: Record<string, string> | null;
categoryId?: number | null;
category?: {
id: number;
nameFa: string;
nameEn?: string;
colors?: string[];
sizes?: string[];
packages?: string[];
technicalFields?: Array<{
id: string;
labelFa: string;
labelEn?: string;
type?: string;
}> | null;
} | null;
brand?: { id: string; nameFa: string; imageUrl?: string | null } | null;
variants?: Array<{
id: string;
colorId: string;
sizeId: string;
packageId: string;
price: number;
discountPrice?: number | null;
}>;
};
function formatPrice(price: number) {
return new Intl.NumberFormat("de-DE").format(price);
}
const FALLBACK_IMAGE = "/images/home/products/coat-blue.jpg";
export function mapApiProductToCard(p: ApiProduct): Product {
const display = p.discountPrice && p.discountPrice > 0 ? p.discountPrice : p.price;
const gallery = p.galleryUrls?.filter(Boolean) ?? [];
return {
id: p.id,
nameFa: p.nameFa || "بدون نام",
nameEn: p.nameEn || "",
price: formatPrice(display || 0),
image: p.imageUrl || gallery[0] || FALLBACK_IMAGE,
imageHover: gallery[1] || gallery[0] || p.imageUrl || undefined,
href: productPath(p),
};
}
const COLOR_CATALOG: Record<
string,
{ nameFa: string; nameEn: string; hex: string }
> = {
black: { nameFa: "مشکی", nameEn: "Black", hex: "#0a0a0a" },
white: { nameFa: "سفید", nameEn: "White", hex: "#f5f5f5" },
cream: { nameFa: "کرم", nameEn: "Cream", hex: "#e8dcc8" },
beige: { nameFa: "بژ", nameEn: "Beige", hex: "#c4a882" },
navy: { nameFa: "سرمه‌ای", nameEn: "Navy", hex: "#1b2a4a" },
olive: { nameFa: "زیتونی", nameEn: "Olive", hex: "#556b2f" },
burgundy: { nameFa: "زرشکی", nameEn: "Burgundy", hex: "#6b1e2a" },
gray: { nameFa: "طوسی", nameEn: "Gray", hex: "#8a8a8a" },
brown: { nameFa: "قهوه‌ای", nameEn: "Brown", hex: "#6b4423" },
red: { nameFa: "قرمز", nameEn: "Red", hex: "#b91c1c" },
blue: { nameFa: "آبی", nameEn: "Blue", hex: "#2f4a6e" },
};
const SIZE_CATALOG: Record<string, { nameFa: string; nameEn: string }> = {
xs: { nameFa: "XS", nameEn: "XS" },
s: { nameFa: "S", nameEn: "S" },
m: { nameFa: "M", nameEn: "M" },
l: { nameFa: "L", nameEn: "L" },
xl: { nameFa: "XL", nameEn: "XL" },
xxl: { nameFa: "XXL", nameEn: "XXL" },
};
const PACKAGE_CATALOG: Record<string, { nameFa: string; nameEn: string }> = {
"6": { nameFa: "۶ تایی", nameEn: "6 pcs" },
"12": { nameFa: "۱۲ تایی", nameEn: "12 pcs" },
};
function uniqueOptions(
values: string[],
kind: "color" | "size" | "package",
): ProductVariantOption[] {
const seen = new Set<string>();
const out: ProductVariantOption[] = [];
for (const raw of values) {
const id = raw.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
if (kind === "color") {
const known = COLOR_CATALOG[id.toLowerCase()];
const swatch = id.startsWith("#") ? id : known?.hex;
out.push({
id,
labelFa: known?.nameFa ?? id,
labelEn: known?.nameEn ?? id,
swatch,
});
continue;
}
if (kind === "size") {
const known = SIZE_CATALOG[id.toLowerCase()];
const label = (known?.nameEn ?? id).toUpperCase();
out.push({
id,
labelFa: label,
labelEn: label,
});
continue;
}
const known = PACKAGE_CATALOG[id];
out.push({
id,
labelFa: known?.nameFa ?? id,
labelEn: known?.nameEn ?? id,
});
}
return out;
}
export function mapApiProductToDetail(p: ApiProduct): ProductDetail {
const card = mapApiProductToCard(p);
const gallery = [
p.imageUrl,
...(p.galleryUrls ?? []),
].filter((src, i, arr): src is string => Boolean(src) && arr.indexOf(src!) === i);
const variantColors = (p.variants ?? []).map((v) => v.colorId).filter(Boolean);
const variantSizes = (p.variants ?? []).map((v) => v.sizeId).filter(Boolean);
const variantPackages = (p.variants ?? [])
.map((v) => v.packageId)
.filter(Boolean);
const hasVariants = (p.variants ?? []).length > 0;
const colors = uniqueOptions(
[...(p.category?.colors ?? []), ...variantColors],
"color",
);
const sizes = uniqueOptions(
[...(p.category?.sizes ?? []), ...variantSizes],
"size",
);
// Only show package options when variants actually use packages
// (category may still list packages that this product doesn't sell).
const packages = uniqueOptions(
hasVariants ? variantPackages : (p.category?.packages ?? []),
"package",
);
const techFields = Array.isArray(p.category?.technicalFields)
? p.category!.technicalFields!
: [];
const techData = (p.technicalData ?? {}) as Record<string, string>;
const technical =
techFields.length > 0
? techFields.map((field) => ({
labelFa: field.labelFa,
labelEn: field.labelEn || field.id,
value: techData[field.id] || "—",
}))
: Object.entries(techData).map(([key, value]) => ({
labelFa: key,
labelEn: key,
value,
}));
return {
...card,
summary: p.summary || p.description?.slice(0, 140) || card.nameFa,
description: p.description || p.summary || "",
gallery: gallery.length ? gallery : [card.image],
colors,
sizes,
packages,
technical,
comments: [],
sizeGuideImage: null,
sizeGuideNote:
"اندازه‌ها بر حسب سانتی‌متر است. اگر بین دو سایز هستید، سایز بزرگ‌تر را انتخاب کنید.",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
...(p.category
? [
{
label: p.category.nameFa,
href: categoryPath(p.category),
},
]
: []),
{ label: p.nameFa },
],
};
}
export type PublicProductsResult = {
ok: boolean;
products: ApiProduct[];
};
export async function fetchPublicProducts(query?: {
name?: string;
categoryId?: string | number;
minPrice?: number;
maxPrice?: number;
}): Promise<PublicProductsResult> {
const params = new URLSearchParams({ storeSlug: STORE_SLUG });
if (query?.name) params.set("name", query.name);
if (query?.categoryId !== undefined && query.categoryId !== "") {
params.set("categoryId", String(query.categoryId));
}
if (query?.minPrice !== undefined) params.set("minPrice", String(query.minPrice));
if (query?.maxPrice !== undefined) params.set("maxPrice", String(query.maxPrice));
const data = await publicFetch<ApiProduct[]>(
`/public/products?${params.toString()}`,
);
if (data === null) return { ok: false, products: [] };
return { ok: true, products: Array.isArray(data) ? data : [] };
}
export async function fetchPublicProduct(
id: string | number,
): Promise<ApiProduct | null> {
return publicFetch<ApiProduct>(`/public/products/${encodeURIComponent(String(id))}`, {
revalidate: 15,
});
}
export async function searchPublic(q: string, limit = 12) {
const params = new URLSearchParams({
storeSlug: STORE_SLUG,
q,
limit: String(limit),
});
return (
(await publicFetch<{
products: ApiProduct[];
categories: Array<{
id: number;
nameFa: string;
nameEn: string;
parentId: number | null;
}>;
}>(`/public/search?${params.toString()}`, { revalidate: false })) ?? {
products: [],
categories: [],
}
);
}
function fallbackCards(): Product[] {
return SALE_PRODUCTS.map((p) => ({
...p,
href: productPath(p),
}));
}
/** Card list — static fallback only when API is unreachable */
export async function getStorefrontProducts(query?: {
name?: string;
categoryId?: string | number;
tag?: string;
}): Promise<Product[]> {
const { ok, products: rows } = await fetchPublicProducts({
name: query?.name,
categoryId: query?.categoryId,
});
if (!ok) {
// Offline: ignore filters and show static sample cards
if (query?.name || query?.categoryId) return [];
return fallbackCards();
}
let filtered = rows;
if (query?.tag) {
const tag = query.tag.toLowerCase();
const tagged = rows.filter((p) =>
(p.tags ?? []).some((t) => t.toLowerCase().includes(tag)),
);
if (tagged.length) filtered = tagged;
}
return filtered.map(mapApiProductToCard);
}
export async function getStorefrontCatalog() {
const { ok, products: rows } = await fetchPublicProducts();
if (!ok || !rows.length) {
const fallback = fallbackCards();
return {
all: fallback,
promo1: fallback,
promo2: fallback,
promo3: fallback,
sale: fallback,
autumn: fallback,
};
}
const bySlot = (slot: string) =>
rows
.filter((p) => p.featuredSlot === slot)
.map(mapApiProductToCard);
const cards = rows.map(mapApiProductToCard);
const promo1 = bySlot("promo-1");
const promo2 = bySlot("promo-2");
const promo3 = bySlot("promo-3");
// Prefer featured placements; fall back so carousels are never empty
return {
all: cards,
promo1: promo1.length ? promo1 : cards,
promo2: promo2.length ? promo2 : cards,
promo3: promo3.length ? promo3 : cards,
sale: promo1.length ? promo1 : cards,
autumn: promo3.length ? promo3 : cards,
};
}
+30
View File
@@ -0,0 +1,30 @@
export const API_URL =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
"http://localhost:3002/api/v1";
export const STORE_SLUG =
process.env.NEXT_PUBLIC_STORE_SLUG?.trim() || "mana-collection";
export const STORE_URL =
process.env.NEXT_PUBLIC_STORE_URL?.replace(/\/$/, "") ||
"http://localhost:3001";
export async function publicFetch<T>(
path: string,
init?: RequestInit & { revalidate?: number | false },
): Promise<T | null> {
const { revalidate = 30, ...rest } = init ?? {};
try {
const res = await fetch(`${API_URL}${path}`, {
...rest,
next:
revalidate === false
? { revalidate: 0 }
: { revalidate: revalidate ?? 30 },
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { publicFetch, STORE_SLUG } from "@/lib/public-api";
export type PublicWebsiteSlotItem = {
id: string;
slotKey: string;
sortOrder: number;
nameFa: string;
nameEn: string;
subtext: string;
linkText: string;
link: string;
imageUrl: string | null;
};
export async function fetchPublicWebsiteSlots(): Promise<
PublicWebsiteSlotItem[]
> {
const data = await publicFetch<{ items?: PublicWebsiteSlotItem[] }>(
`/public/website-slots?storeSlug=${encodeURIComponent(STORE_SLUG)}`,
);
return data?.items ?? [];
}
export function itemsForSlot(
items: PublicWebsiteSlotItem[],
slotKey: string,
): PublicWebsiteSlotItem[] {
return items
.filter((item) => item.slotKey === slotKey)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export function firstForSlot(
items: PublicWebsiteSlotItem[],
slotKey: string,
) {
return itemsForSlot(items, slotKey)[0] ?? null;
}