Connect storefront to backend catalog, search, and home slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2f857f1a90
commit
0074ab6629
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user