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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 2f857f1a90
commit 0074ab6629
106 changed files with 5920 additions and 81 deletions
+77
View File
@@ -0,0 +1,77 @@
import Image from "next/image";
import { ABOUT } from "@/data/home";
type AboutSectionProps = {
titleFa?: string;
titleEn?: string;
lead?: string;
body?: string;
image?: string;
imageSecondary?: string;
};
export default function AboutSection({
titleFa = ABOUT.titleFa,
titleEn = ABOUT.titleEn,
lead = ABOUT.lead,
body = ABOUT.body,
image = ABOUT.image,
imageSecondary = ABOUT.imageSecondary,
}: AboutSectionProps) {
return (
<section id="about" dir="rtl" className="scroll-mt-24 bg-white py-16 lg:py-24">
<div className="mx-auto grid max-w-[1200px] items-center gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:gap-16 lg:px-8">
<div className="relative mx-auto w-full max-w-[520px] lg:mx-0">
<div
aria-hidden
className="absolute -inset-x-4 top-8 bottom-0 -z-0 rounded-[5px] border border-ink/15 bg-[#f3f3f3] sm:-inset-x-6 lg:inset-x-0 lg:-start-8 lg:end-12 lg:top-12"
style={{
backgroundImage:
"radial-gradient(circle, rgba(10,10,10,0.14) 1px, transparent 1.2px)",
backgroundSize: "14px 14px",
}}
/>
<div className="relative z-10 aspect-[4/5] w-[78%] overflow-hidden rounded-[5px] bg-[#ebe6df]">
<Image
src={image}
alt={titleFa}
fill
sizes="(max-width: 1024px) 70vw, 380px"
className="object-cover object-[center_18%]"
/>
</div>
<div className="absolute bottom-0 end-0 z-20 aspect-[3/4] w-[48%] overflow-hidden rounded-[5px] border border-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
<Image
src={imageSecondary}
alt=""
fill
sizes="(max-width: 1024px) 40vw, 240px"
className="object-cover object-top"
/>
</div>
</div>
<div className="relative text-right">
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute -top-4 start-0 font-doran text-[clamp(2rem,5vw,3.5rem)] font-normal tracking-[0.08em] text-ink/10"
>
{titleEn}
</p>
<h2 className="font-doran relative text-[clamp(1.75rem,3vw,2.5rem)] font-light text-ink">
{titleFa}
</h2>
<p className="font-doran mt-4 text-base font-medium text-ink sm:text-lg">
{lead}
</p>
<p className="mt-4 text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
{body}
</p>
</div>
</div>
</section>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { BLACK_ITEMS } from "@/data/home";
export type BlackItemData = {
id: string;
image: string;
alt: string;
nameFa: string;
nameEn: string;
href: string;
};
type BlackCollectionProps = {
items?: BlackItemData[];
};
export default function BlackCollection({ items }: BlackCollectionProps) {
const list: BlackItemData[] =
items?.length
? items
: BLACK_ITEMS.map((item) => ({
id: item.id,
image: item.image,
alt: item.alt,
nameFa: item.nameFa,
nameEn: item.nameEn,
href: `/products?color=black&item=${item.id}`,
}));
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section
ref={ref}
className="relative mx-auto w-full max-w-[1710px] px-4 py-10 sm:px-6 lg:px-[120px] lg:py-14"
>
<div
className="seq-fade-item relative mb-8 flex items-center justify-center text-center lg:mb-10"
style={{
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
}}
>
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center font-doran text-[clamp(2.5rem,8vw,6.25rem)] font-normal leading-none tracking-[0.1em] text-ink/10"
>
i Like it Black
</p>
<h2 className="font-doran relative translate-y-[0.12em] text-[clamp(1.75rem,4vw,3.125rem)] font-light leading-none text-ink">
من عاشق رنگ <span className="font-medium">مشکی</span> هستم
</h2>
</div>
<ul className="grid grid-cols-2 gap-[14px] lg:grid-cols-4 lg:gap-[22px]">
{list.map((item, i) => (
<li
key={item.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${(i + 1) * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}}
>
<Link
href={item.href}
className="group relative block aspect-[410/717] overflow-hidden bg-[#f3f3f3]"
>
<Image
src={item.image}
alt={item.alt}
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
<div className="absolute inset-x-3 bottom-3 z-10 overflow-hidden bg-white px-3 py-2.5 text-center sm:inset-x-4 sm:bottom-4 sm:px-4 sm:py-3">
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<p className="relative z-10 font-doran text-base font-medium text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg lg:text-xl">
{item.nameFa}
</p>
<p
dir="ltr"
className="relative z-10 mt-0.5 font-montserrat text-[10px] font-light uppercase tracking-[0.28em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-[11px]"
>
{item.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CATEGORIES, type Category } from "@/data/home";
type CategoryStripProps = {
categories?: Category[];
};
export default function CategoryStrip({ categories }: CategoryStripProps) {
const list = categories?.length ? categories : CATEGORIES;
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1600px] px-4 py-20 sm:px-6 sm:py-24 lg:px-10 lg:py-32">
<ul
ref={ref}
className="flex flex-wrap items-stretch justify-center gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8"
>
{list.map((category, i) => (
<li
key={category.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${i * 90}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Link
href={category.href ?? "#"}
className="group relative flex w-[140px] flex-col items-center gap-2 overflow-hidden px-3 py-5 text-center sm:w-[160px] sm:px-4 sm:py-6 lg:w-[180px]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<div className="relative z-10 h-[72px] w-[68px] overflow-hidden sm:h-[80px] sm:w-[76px]">
<Image
src={category.image}
alt={category.nameFa}
fill
sizes="76px"
className="object-contain transition-[filter,transform] duration-[400ms] ease-out group-hover:invert"
/>
</div>
<div className="relative z-10 w-full">
<p className="whitespace-nowrap text-base font-light text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg">
{category.nameFa}
</p>
<p
dir="ltr"
className="whitespace-nowrap font-montserrat text-[11px] font-light uppercase tracking-[0.35em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-xs"
>
{category.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { FEATURES } from "@/data/home";
export default function FeaturesBar() {
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1200px] px-4 py-12 sm:px-6 lg:py-16">
<ul ref={ref} className="grid grid-cols-2 gap-8 lg:grid-cols-4 lg:gap-0">
{FEATURES.map((feature, index) => (
<li
key={feature.id}
className={`seq-fade-item flex flex-col items-center px-4 text-center ${
index < FEATURES.length - 1
? "lg:border-s lg:border-ink/15"
: ""
}`}
style={{
transitionDelay: visible ? `${index * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Image
src={feature.icon}
alt=""
width={44}
height={34}
unoptimized
className="mb-3 h-9 w-auto"
/>
<p className="font-doran text-lg font-medium tracking-tight text-ink sm:text-[22px]">
{feature.title}
</p>
<p className="mt-1 text-sm font-light text-ink/70 sm:text-[15px]">
{feature.subtitle}
</p>
</li>
))}
</ul>
</section>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { categoryPath } from "@/lib/category-path";
import { productPath } from "@/lib/product-path";
import { API_URL, STORE_SLUG } from "@/lib/public-api";
type SearchHit = {
id: number | string;
nameFa: string;
nameEn: string;
imageUrl?: string | null;
price?: number;
};
type CategoryHit = {
id: number;
nameFa: string;
nameEn: string;
};
export default function HeaderSearch() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<SearchHit[]>([]);
const [categories, setCategories] = useState<CategoryHit[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open) return;
const query = q.trim();
if (query.length < 2) {
setProducts([]);
setCategories([]);
return;
}
const t = window.setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
storeSlug: STORE_SLUG,
q: query,
limit: "8",
});
const res = await fetch(`${API_URL}/public/search?${params}`);
if (!res.ok) throw new Error("search failed");
const data = (await res.json()) as {
products?: SearchHit[];
categories?: CategoryHit[];
};
setProducts(data.products ?? []);
setCategories(data.categories ?? []);
} catch {
setProducts([]);
setCategories([]);
} finally {
setLoading(false);
}
}, 280);
return () => window.clearTimeout(t);
}, [q, open]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const query = q.trim();
if (!query) return;
setOpen(false);
window.location.href = `/products?q=${encodeURIComponent(query)}`;
}
return (
<>
<button
type="button"
aria-label="جستجو"
aria-expanded={open}
className="transition-opacity hover:opacity-70"
onClick={() => setOpen(true)}
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
{open ? (
<div className="fixed inset-0 z-[70]">
<button
type="button"
className="absolute inset-0 bg-ink/40"
aria-label="بستن جستجو"
onClick={() => setOpen(false)}
/>
<div
ref={panelRef}
className="absolute inset-x-0 top-0 bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
>
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-[900px] items-center gap-3 px-4 py-4 sm:px-6"
dir="rtl"
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={18}
height={18}
unoptimized
/>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی محصول یا دسته‌بندی..."
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
/>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-muted hover:text-ink"
>
بستن
</button>
</form>
<div className="mx-auto max-w-[900px] border-t border-line px-4 py-4 sm:px-6">
{loading ? (
<p className="text-sm text-muted">در حال جستجو...</p>
) : q.trim().length < 2 ? (
<p className="text-sm text-muted">
حداقل ۲ حرف وارد کنید.
</p>
) : !products.length && !categories.length ? (
<p className="text-sm text-muted">نتیجه‌ای یافت نشد.</p>
) : (
<div className="grid gap-6 sm:grid-cols-2">
{categories.length ? (
<div>
<p className="mb-2 text-xs text-muted">دسته‌بندی‌ها</p>
<ul className="space-y-1">
{categories.map((c) => (
<li key={c.id}>
<Link
href={categoryPath(c)}
onClick={() => setOpen(false)}
className="block border border-transparent px-2 py-2 text-sm hover:border-line hover:bg-[#f7f7f5]"
>
{c.nameFa}
{c.nameEn ? (
<span
dir="ltr"
className="ms-2 font-montserrat text-[10px] text-muted"
>
{c.nameEn}
</span>
) : null}
</Link>
</li>
))}
</ul>
</div>
) : null}
{products.length ? (
<div>
<p className="mb-2 text-xs text-muted">محصولات</p>
<ul className="space-y-1">
{products.map((p) => (
<li key={p.id}>
<Link
href={productPath(p)}
onClick={() => setOpen(false)}
className="flex items-center gap-3 border border-transparent px-2 py-2 hover:border-line hover:bg-[#f7f7f5]"
>
<span className="relative h-12 w-10 shrink-0 overflow-hidden bg-[#f3f3f1]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
p.imageUrl ||
"/images/home/products/coat-blue.jpg"
}
alt=""
className="h-full w-full object-cover"
/>
</span>
<span className="min-w-0">
<span className="block truncate text-sm text-ink">
{p.nameFa}
</span>
<span
dir="ltr"
className="block truncate font-montserrat text-[10px] text-muted"
>
{p.nameEn}
</span>
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { STORE_URL } from "@/lib/public-api";
/**
* Website (3000) and dashboards (3001) don't share localStorage on localhost.
* We still point at the store app: login when unknown, account/dashboard when a
* same-origin token exists (production shared domain), else login entry.
*/
export default function HeaderUserLink() {
const [href, setHref] = useState(`${STORE_URL}/login`);
const [label, setLabel] = useState("ورود");
useEffect(() => {
try {
const token = localStorage.getItem("mana_access_token");
const raw = localStorage.getItem("mana_user");
if (!token) {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
return;
}
let role = "";
if (raw) {
const user = JSON.parse(raw) as { role?: string };
role = user.role ?? "";
}
if (role === "STORE_ADMIN" || role === "SUPER_ADMIN") {
setHref(`${STORE_URL}/`);
setLabel("داشبورد");
} else {
setHref(`${STORE_URL}/account`);
setLabel("حساب کاربری");
}
} catch {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
}
}, []);
return (
<Link
href={href}
aria-label={label}
title={label}
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/user.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import HoverFillButton from "./HoverFillButton";
export type HeroSlideData = {
id: string;
image: string;
imagePosition?: string;
title: string;
subtitle: string;
cta: { label: string; href: string };
};
const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "autumn",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_20%]",
title: "برای بانویی که خودش انتخاب میکند.",
subtitle: "made for her",
cta: { label: "کالکشن پاییزه", href: "/products?collection=autumn" },
},
{
id: "new",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_40%]",
title: "جدیدترین‌های فصل همین حالا ببینید.",
subtitle: "new arrivals",
cta: { label: "جدیدترین‌ها", href: "/products?sort=newest" },
},
{
id: "sale",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_55%]",
title: "فروش ویژه تا ۷۰٪ تخفیف",
subtitle: "up to 70% off",
cta: { label: "مشاهده تخفیف‌ها", href: "/products?sale=1" },
},
];
const AUTO_MS = 10000;
type HeroSliderProps = {
slides?: HeroSlideData[];
};
export default function HeroSlider({ slides }: HeroSliderProps) {
const list = useMemo(
() => (slides?.length ? slides : FALLBACK_SLIDES),
[slides],
);
const [index, setIndex] = useState(0);
const [textKey, setTextKey] = useState(0);
const [paused, setPaused] = useState(false);
const slide = list[index] ?? list[0];
useEffect(() => {
setIndex(0);
}, [list]);
const goTo = useCallback((next: number) => {
setIndex(next);
setTextKey((k) => k + 1);
}, []);
const goPrev = () => goTo((index - 1 + list.length) % list.length);
const goNext = () => goTo((index + 1) % list.length);
useEffect(() => {
if (paused || list.length <= 1) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const t = window.setInterval(() => {
setIndex((i) => (i + 1) % list.length);
setTextKey((k) => k + 1);
}, AUTO_MS);
return () => window.clearInterval(t);
}, [index, paused, list.length]);
if (!slide) return null;
return (
<section
className="relative h-[min(100dvh,1081px)] min-h-[560px] w-full overflow-hidden bg-[#ebe6df]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{list.map((s, i) => {
const active = i === index;
return (
<div
key={s.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-out ${
active ? "opacity-100" : "opacity-0"
}`}
aria-hidden={!active}
>
<Image
key={active ? `on-${s.id}-${index}` : `off-${s.id}`}
src={s.image}
alt=""
fill
priority={i === 0}
sizes="100vw"
className={`object-cover ${s.imagePosition ?? "object-center"} ${
active ? "hero-ken-burns" : ""
} ${active && paused ? "hero-ken-burns-paused" : ""}`}
/>
</div>
);
})}
<div className="absolute inset-0 bg-gradient-to-l from-white/25 via-transparent to-transparent" />
<div className="absolute inset-y-0 right-16 z-10 flex w-[min(360px,calc(100%-6.5rem))] items-center sm:right-24 lg:right-[200px]">
<div
key={textKey}
dir="rtl"
className="hero-copy-in w-full pt-16 text-right"
>
<h1 className="hero-copy-item whitespace-pre-line font-doran text-right text-[clamp(1.25rem,2.2vw,2.25rem)] font-medium leading-[1.4] text-ink">
{slide.title}
</h1>
{slide.subtitle ? (
<p
dir="ltr"
className="hero-copy-item hero-copy-item-2 mt-3 block w-full whitespace-pre-line text-right font-montserrat text-[clamp(0.875rem,1.4vw,1.35rem)] font-light uppercase tracking-[0.28em] text-[#6d6d6d]"
>
{slide.subtitle}
</p>
) : null}
{slide.cta.label ? (
<div className="hero-copy-item hero-copy-item-3">
<HoverFillButton href={slide.cta.href || "#"} className="mt-7 w-full">
{slide.cta.label}
</HoverFillButton>
</div>
) : null}
</div>
</div>
{list.length > 1 ? (
<>
<div className="pointer-events-none absolute inset-y-0 left-4 z-20 flex items-center sm:left-8 lg:left-[120px]">
<button
type="button"
aria-label="اسلاید قبلی"
onClick={goPrev}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 right-4 z-20 flex items-center sm:right-8 lg:right-[120px]">
<button
type="button"
aria-label="اسلاید بعدی"
onClick={goNext}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div
dir="ltr"
className="absolute inset-x-0 bottom-8 z-20 flex items-center justify-center gap-2.5 sm:bottom-12"
role="tablist"
aria-label="اسلایدهای هیرو"
>
{list.map((s, i) => {
const active = i === index;
return (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`اسلاید ${i + 1}`}
onClick={() => goTo(i)}
className={`h-2 rounded-full transition-all duration-300 ${
active
? "w-8 bg-ink"
: "w-2 bg-ink/30 hover:bg-ink/50"
}`}
/>
);
})}
</div>
</>
) : null}
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
type HoverFillButtonProps = {
href: string;
children: ReactNode;
className?: string;
/** `light` = ink border on pale surfaces; `dark` = white border on ink surfaces */
variant?: "light" | "dark";
};
export default function HoverFillButton({
href,
children,
className = "",
variant = "light",
}: HoverFillButtonProps) {
const isDark = variant === "dark";
return (
<Link
href={href}
dir="rtl"
className={`group relative inline-flex h-[52px] items-center justify-between gap-3 overflow-hidden rounded-[5px] border px-4 backdrop-blur-sm sm:h-[56px] ${
isDark
? "border-white bg-transparent text-white"
: "border-ink bg-white/30 text-ink"
} ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 origin-bottom scale-y-0 transition-transform duration-[400ms] ease-out group-hover:scale-y-100 ${
isDark ? "bg-white" : "bg-ink"
}`}
/>
<span
className={`relative z-10 text-base font-light transition-colors duration-[400ms] ease-out sm:text-xl ${
isDark ? "group-hover:text-ink" : "group-hover:text-white"
}`}
>
{children}
</span>
<Image
src={
isDark
? "/images/home/icons/caret-left.svg"
: "/images/home/icons/caret-left-dark.svg"
}
alt=""
width={28}
height={28}
unoptimized
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert"
/>
</Link>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Image from "next/image";
import HoverFillButton from "./HoverFillButton";
type LeatherBannerProps = {
image?: string;
nameFa?: string;
nameEn?: string;
linkText?: string;
link?: string;
};
export default function LeatherBanner({
image = "/images/home/banners/leather.png",
nameFa = "هنر پوشیدن",
nameEn = "THE ART OF DRESSING",
linkText = "کالکشن چرم",
link = "/products?collection=leather",
}: LeatherBannerProps) {
const enLines = nameEn.trim().split(/\n+/).filter(Boolean);
const line1 = enLines[0] ?? "THE ART";
const line2 = enLines[1] ?? (enLines.length === 1 ? "" : "OF DRESSING");
// If single-line English like "THE ART OF DRESSING", split roughly in half
const [display1, display2] =
enLines.length === 1 && line1.includes(" ")
? (() => {
const parts = line1.split(/\s+/);
const mid = Math.ceil(parts.length / 2);
return [parts.slice(0, mid).join(" "), parts.slice(mid).join(" ")];
})()
: [line1, line2 || "OF DRESSING"];
return (
<section className="relative w-full overflow-hidden bg-white">
<div className="relative mx-auto w-full max-w-[1920px] aspect-[1920/585]">
<Image
src={image}
alt={nameFa}
fill
sizes="100vw"
className="object-cover object-left-bottom"
/>
<div className="absolute inset-y-0 right-4 z-10 flex items-center sm:right-10 lg:right-[max(2rem,calc((100%-1709px)/2+186px))]">
<div
dir="ltr"
className="flex w-max max-w-[min(100%,42vw)] flex-col items-stretch text-left text-ink"
>
<p className="whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display1}
</p>
{display2 ? (
<p className="mt-1 whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display2}
</p>
) : null}
<p
dir="rtl"
className="mt-3 whitespace-nowrap font-doran text-[clamp(1.25rem,3.2vw,3.5rem)] font-light leading-none"
>
{nameFa}
</p>
{linkText ? (
<HoverFillButton
href={link || "#"}
className="mt-4 w-full !h-11 sm:!h-12"
>
{linkText}
</HoverFillButton>
) : null}
</div>
</div>
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import HoverFillButton from "./HoverFillButton";
export default function PartnerCta() {
return (
<section dir="rtl" className="bg-ink text-white">
<div className="mx-auto flex max-w-[1920px] flex-col items-stretch justify-between gap-6 px-4 py-14 sm:px-6 lg:flex-row lg:items-center lg:px-[120px] lg:py-16">
<div className="text-right lg:max-w-[560px]">
<p className="font-doran text-base font-normal sm:text-lg">
مایل هستید ما محصول شما را برای شما بفروشیم؟
</p>
<p className="mt-1.5 text-sm font-light text-white/85 sm:text-base">
اگر تولید کننده هستید، ما مایل به همکاری با شما هستیم
</p>
</div>
<HoverFillButton
href="/partners"
variant="dark"
className="!h-10 w-fit max-w-none shrink-0 gap-2 self-start px-3 sm:!h-11 sm:self-center [&_span]:!text-sm sm:[&_span]:!text-base [&_img]:!h-4 [&_img]:!w-4"
>
شروع به همکاری
</HoverFillButton>
</div>
</section>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Image from "next/image";
import Link from "next/link";
import type { Product } from "@/data/home";
import { productPath } from "@/lib/product-path";
type ProductCardProps = {
product: Product;
className?: string;
};
export default function ProductCard({ product, className = "" }: ProductCardProps) {
const href = product.href ?? productPath(product);
const hoverSrc = product.imageHover;
return (
<article
dir="rtl"
className={`group relative flex h-[491px] w-full flex-col rounded-[5px] border border-[#8e8e8e]/30 bg-white transition-colors duration-300 ease-out hover:border-ink ${className}`}
>
<Link href={href} className="flex min-h-0 flex-1 flex-col">
{/* Clip media only — keep border on the outer box so it never gets cut off */}
<span className="relative block min-h-0 flex-1 overflow-hidden rounded-t-[4px] p-2.5">
<span className="relative block h-full w-full overflow-hidden rounded-[5px]">
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 324px"
className={`object-cover object-top transition-transform duration-500 ease-out ${
hoverSrc ? "group-hover:translate-x-full" : "group-hover:scale-[1.04]"
}`}
/>
{hoverSrc ? (
<Image
src={hoverSrc}
alt=""
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-top -translate-x-full transition-transform duration-500 ease-out group-hover:translate-x-0"
aria-hidden
/>
) : null}
</span>
</span>
<span className="flex shrink-0 flex-col gap-2 px-3.5 pb-3.5 pt-1">
<span className="w-full text-right">
<span className="line-clamp-1 block text-[15px] font-medium text-ink">
{product.nameFa}
</span>
<span
dir="ltr"
className="mt-0.5 line-clamp-1 block w-full text-right font-montserrat text-[9px] font-light uppercase tracking-[0.2em] text-[#afafaf]"
>
{product.nameEn}
</span>
</span>
<span
dir="ltr"
className="mt-1 flex items-baseline justify-start gap-1.5 border-t border-ink/15 pt-3 pb-0.5 transition-colors duration-300 ease-out group-hover:border-ink"
>
<span className="font-montserrat text-base font-normal leading-none text-ink sm:text-[17px]">
{product.price}
</span>
<span className="text-[9px] leading-none text-[#878787]">IRT</span>
</span>
</span>
</Link>
<button
type="button"
aria-label="افزودن به علاقه‌مندی"
className="absolute end-3 top-4 z-10 transition-opacity hover:opacity-70"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Image
src="/images/home/icons/bookmark.svg"
alt=""
width={24}
height={24}
unoptimized
/>
</button>
</article>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "./ProductCard";
import PromoCard, { type PromoSlotOverride } from "./PromoCard";
type ProductRowProps = {
products: Product[];
promo: "sale" | "discount" | "autumn";
className?: string;
/** Stagger fade-in for promo + product cards when scrolled into view */
stagger?: boolean;
promoSlot?: PromoSlotOverride | null;
};
const CARD_W =
"w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
export default function ProductRow({
products,
promo,
className = "",
stagger = false,
promoSlot,
}: ProductRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const offsetRef = useRef(0);
const maxRef = useRef(0);
const stepRef = useRef(288);
const [offset, setOffset] = useState(0);
const [maxOffset, setMaxOffset] = useState(0);
const [visible, setVisible] = useState(!stagger);
const dragRef = useRef<{
pointerId: number | null;
startX: number;
startOffset: number;
dragging: boolean;
}>({ pointerId: null, startX: 0, startOffset: 0, dragging: false });
const applyOffset = useCallback((value: number) => {
const next = Math.min(maxRef.current, Math.max(0, value));
offsetRef.current = next;
setOffset(next);
}, []);
const measure = useCallback(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const card = track.querySelector<HTMLElement>("[data-carousel-card]");
const styles = getComputedStyle(track);
const gap = parseFloat(styles.columnGap || styles.gap || "8") || 8;
const cardW = card?.offsetWidth ?? 280;
stepRef.current = cardW + gap;
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
setMaxOffset(maxRef.current);
applyOffset(offsetRef.current);
}, [applyOffset]);
useEffect(() => {
measure();
const viewport = viewportRef.current;
if (!viewport) return;
const ro = new ResizeObserver(() => measure());
ro.observe(viewport);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
const t = window.setTimeout(measure, 200);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products, promo]);
useEffect(() => {
if (!stagger) return;
const el = sectionRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, [stagger]);
const go = (direction: "prev" | "next") => {
const delta = direction === "next" ? stepRef.current : -stepRef.current;
applyOffset(offsetRef.current + delta);
};
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
// Never steal pointer from links/buttons — cards are full <a> links
if (target.closest("a, button")) return;
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startOffset: offsetRef.current,
dragging: false,
};
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const dx = drag.startX - e.clientX;
if (!drag.dragging) {
// Wait for a real drag before capturing — plain clicks still hit <a>
if (Math.abs(e.clientX - drag.startX) < 8) return;
drag.dragging = true;
viewport.setPointerCapture(e.pointerId);
if (trackRef.current) trackRef.current.style.transition = "none";
}
applyOffset(drag.startOffset + dx);
e.preventDefault();
};
const onPointerUp = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const wasDragging = drag.dragging;
drag.pointerId = null;
drag.dragging = false;
if (trackRef.current) trackRef.current.style.transition = "";
if (wasDragging) {
try {
viewport.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
const suppress = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
viewport.removeEventListener("click", suppress, true);
};
viewport.addEventListener("click", suppress, true);
window.setTimeout(
() => viewport.removeEventListener("click", suppress, true),
0,
);
}
};
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove, { passive: false });
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
return () => {
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
};
}, [applyOffset]);
return (
<section
ref={sectionRef}
dir="rtl"
className={`relative w-full pl-4 sm:pl-6 lg:pl-[120px] pr-8 sm:pr-12 lg:pr-[180px] ${className}`}
>
<div className="flex items-stretch gap-2 sm:gap-2.5 lg:gap-3">
<div
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<PromoCard variant={promo} className="max-w-none" slot={promoSlot} />
</div>
<div
ref={viewportRef}
className="relative min-w-0 flex-1 overflow-hidden touch-pan-y"
>
<div
ref={trackRef}
className="flex cursor-grab items-stretch gap-2 transition-transform duration-300 ease-out will-change-transform active:cursor-grabbing sm:gap-2.5 lg:gap-3"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
>
{products.map((product, i) => (
<div
key={`${promo}-${product.id}`}
data-carousel-card
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? `${(i + 1) * 100}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<ProductCard product={product} className="max-w-none" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-y-0 right-1 z-10 flex items-center sm:right-2">
<button
type="button"
aria-label="محصول قبلی"
disabled={offset <= 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("prev");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 left-1 z-10 flex items-center sm:left-2">
<button
type="button"
aria-label="محصول بعدی"
disabled={offset >= maxOffset - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("next");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
</div>
</div>
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { MEGA_MENU_TABS } from "@/data/home";
export type MegaColumn = {
title: string;
href: string;
links: readonly { label: string; href: string }[];
};
export type MegaTabData = {
id: string;
nameFa: string;
nameEn: string;
href: string;
image: string;
/** Second-level categories as columns, each with third-level children */
columns?: readonly MegaColumn[];
/** Flat fallback when columns are not provided */
links?: readonly { label: string; href: string }[];
};
type ProductsMegaMenuProps = {
open: boolean;
onClose: () => void;
tabs?: MegaTabData[];
};
function columnsForTab(tab: MegaTabData): MegaColumn[] {
if (tab.columns?.length) return [...tab.columns];
if (tab.links?.length) {
return [
{
title: tab.nameFa,
href: tab.href,
links: tab.links,
},
];
}
return [];
}
export default function ProductsMegaMenu({
open,
onClose,
tabs,
}: ProductsMegaMenuProps) {
const list = (tabs?.length ? tabs : MEGA_MENU_TABS) as MegaTabData[];
const [activeId, setActiveId] = useState(list[0]?.id ?? "coat");
const active = list.find((t) => t.id === activeId) ?? list[0];
if (!open || !active) return null;
const columns = columnsForTab(active);
return (
<div
id="products-mega-menu"
role="region"
aria-label="منوی محصولات"
className="pointer-events-auto absolute inset-x-0 top-full z-40 border-t border-ink/5 bg-white shadow-[0_16px_40px_rgba(0,0,0,0.1)]"
onMouseLeave={onClose}
>
<div className="mx-auto max-w-[1709px] px-4 py-5 sm:px-6 lg:px-10 lg:py-6">
<div
role="tablist"
aria-label="دسته‌بندی محصولات"
className="flex flex-wrap items-end justify-start gap-x-1 gap-y-1 border-b border-ink/10"
>
{list.map((tab) => {
const selected = tab.id === active.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
id={`mega-tab-${tab.id}`}
aria-controls={`mega-panel-${tab.id}`}
onMouseEnter={() => setActiveId(tab.id)}
onFocus={() => setActiveId(tab.id)}
onClick={() => setActiveId(tab.id)}
className={`-mb-px border-b-2 px-3 py-2.5 text-center transition-colors sm:px-4 ${
selected
? "border-ink"
: "border-transparent hover:opacity-90"
}`}
>
<span
className={`block font-doran text-sm font-medium sm:text-[15px] ${
selected ? "text-ink" : "text-ink/80"
}`}
>
{tab.nameFa}
</span>
<span
dir="ltr"
className={`mt-0.5 block font-montserrat text-[9px] font-light uppercase tracking-[0.22em] sm:text-[10px] ${
selected ? "text-ink/70" : "text-ink/40"
}`}
>
{tab.nameEn}
</span>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`mega-panel-${active.id}`}
aria-labelledby={`mega-tab-${active.id}`}
className="mt-5 flex items-start justify-start gap-6 sm:gap-8 lg:gap-10"
>
<div className="min-w-0 flex-1 pt-1">
<div className="grid grid-cols-2 gap-x-6 gap-y-7 sm:grid-cols-3 sm:gap-x-8 lg:grid-cols-4 xl:grid-cols-5 xl:gap-x-10">
{columns.map((col) => (
<div key={col.href + col.title} className="min-w-0">
<Link
href={col.href}
onClick={onClose}
className="block text-sm font-medium text-ink transition-opacity hover:opacity-70 sm:text-[15px]"
>
{col.title}
</Link>
{col.links.length > 0 ? (
<ul className="mt-2.5 flex flex-col gap-1.5 text-[13px] font-light text-ink/70 sm:text-sm">
{col.links.map((link) => (
<li key={link.href + link.label}>
<Link
href={link.href}
onClick={onClose}
className="transition-colors hover:text-ink"
>
{link.label}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
</div>
<div className="relative hidden h-[160px] w-[120px] shrink-0 overflow-hidden bg-[#f3f3f3] sm:block sm:h-[200px] sm:w-[150px] lg:h-[220px] lg:w-[160px]">
{list.map((tab) => (
<div
key={tab.id}
className={`absolute inset-0 transition-opacity duration-[400ms] ease-out ${
tab.id === active.id ? "opacity-100" : "opacity-0"
}`}
aria-hidden={tab.id !== active.id}
>
<Image
src={tab.image}
alt=""
fill
sizes="160px"
className="object-cover"
/>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import Image from "next/image";
import Link from "next/link";
type PromoVariant = "sale" | "discount" | "autumn";
export type PromoSlotOverride = {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
};
type PromoCardProps = {
variant: PromoVariant;
href?: string;
className?: string;
slot?: PromoSlotOverride | null;
};
const META: Record<
PromoVariant,
{ href: string; labelFa: string }
> = {
sale: { href: "/products?sale=1", labelFa: "" },
discount: { href: "/products?sale=1", labelFa: "بهترین مانتوهای ما" },
autumn: {
href: "/products?collection=autumn",
labelFa: "کالکشن پاییزه",
},
};
const CARD_SHELL =
"relative flex h-[491px] w-full flex-col overflow-hidden rounded-[19px] border border-[#8e8e8e]/30";
/** Full-bleed creative when Website Images provides a slot image. */
function FullImagePromo({
href,
image,
alt,
className,
}: {
href: string;
image: string;
alt: string;
className: string;
}) {
return (
<Link href={href} className={`${CARD_SHELL} bg-white ${className}`}>
<Image
src={image}
alt={alt}
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-center"
/>
</Link>
);
}
export default function PromoCard({
variant,
href,
className = "",
slot,
}: PromoCardProps) {
const meta = META[variant];
const to = slot?.link || href || meta.href;
if (slot?.image) {
return (
<FullImagePromo
href={to}
image={slot.image}
alt={slot.nameFa || slot.nameEn || slot.subtext || "promo"}
className={className}
/>
);
}
if (variant === "sale") {
return (
<Link href={to} dir="ltr" className={`${CARD_SHELL} bg-ink text-white ${className}`}>
<div className="relative flex flex-1 items-center justify-center px-6 pt-10">
<div className="relative h-[200px] w-[140px]">
<Image
src="/images/home/banners/sale-script.png"
alt=""
fill
className="object-contain opacity-45"
sizes="140px"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/images/home/icons/sale-wordmark.svg"
alt="Sale"
width={200}
height={100}
unoptimized
className="h-auto w-[170px] max-w-none"
/>
</div>
</div>
</div>
<div className="px-6 pb-8 text-center">
<p className="whitespace-nowrap font-montserrat text-[15px] font-normal tracking-[0.2em] uppercase sm:text-lg">
Our Season Finale
</p>
<p className="mt-1 whitespace-nowrap font-montserrat text-xl font-semibold tracking-[0.18em] uppercase sm:text-2xl">
Up to 70% OFF
</p>
</div>
</Link>
);
}
if (variant === "discount") {
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 text-ink sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 flex-col items-center justify-center text-center uppercase">
<p className="whitespace-nowrap text-[24px] font-light tracking-[0.7em] sm:text-[30px] sm:tracking-[0.84em]">
Up TO
</p>
<p className="-mt-1 font-montserrat text-[64px] font-medium leading-none tracking-[0.12em] sm:text-[80px] sm:tracking-[0.19em]">
70%
</p>
<p className="-mt-1 whitespace-nowrap font-montserrat text-[24px] font-light tracking-[0.1em] sm:text-[30px] sm:tracking-[0.13em]">
Discount
</p>
</div>
<p className="text-center text-[18px] font-light sm:text-[23px]">
{slot?.nameFa || meta.labelFa}
</p>
</Link>
);
}
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 items-center justify-center">
<div className="relative size-[140px] sm:size-[160px]">
<Image
src="/images/home/banners/autumn-leaf.png"
alt=""
fill
className="object-contain mix-blend-darken"
sizes="160px"
/>
</div>
</div>
<div className="flex flex-col items-center text-center uppercase">
<p className="whitespace-nowrap font-montserrat text-[18px] font-light tracking-[0.04em]">
i wanted it for
</p>
<p className="font-montserrat text-[32px] font-medium tracking-[0.2em]">
Autumn
</p>
<p className="mt-3 text-[18px] font-light normal-case sm:text-[20px]">
{slot?.nameFa || meta.labelFa}
</p>
</div>
</Link>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
/** Stagger delay in ms once visible */
delay?: number;
/** Optional custom y offset in px */
offset?: number;
};
export default function Reveal({
children,
className = "",
delay = 0,
offset = 36,
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.12, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : `translate3d(0,${offset}px,0)`,
filter: visible ? "blur(0)" : "blur(8px)",
transition: `opacity 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, filter 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
willChange: "opacity, transform, filter",
}}
>
{children}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import Image from "next/image";
import Link from "next/link";
import {
FOOTER_CATEGORIES_COL1,
FOOTER_CATEGORIES_COL2,
FOOTER_STORE_LINKS,
SOCIAL_LINKS,
} from "@/data/home";
type SiteFooterProps = {
paymentBadges?: string;
};
export default function SiteFooter({
paymentBadges = "/images/home/banners/payment-badges.png",
}: SiteFooterProps) {
return (
<footer dir="rtl" className="mt-8">
<div className="bg-[#ebebeb]">
<div className="mx-auto flex max-w-[1920px] flex-col items-center justify-between gap-4 px-4 py-5 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="max-w-xl text-center text-sm font-light text-ink sm:text-start sm:text-base">
برای اطلاع از جدیدترین محصولات و تخفیفات، ما را در شبکه‌های اجتماعی
دنبال کنید.
</p>
<ul className="flex items-center gap-5 sm:gap-6">
{SOCIAL_LINKS.map((social) => (
<li key={social.id}>
<a
href={social.href}
aria-label={social.label}
className="transition-opacity hover:opacity-70"
>
<Image
src={social.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</a>
</li>
))}
</ul>
</div>
</div>
<div id="contact" className="scroll-mt-24 bg-[#242424] text-white">
<div className="mx-auto grid max-w-[1920px] gap-8 px-4 py-10 sm:px-6 lg:grid-cols-4 lg:gap-8 lg:px-[120px] lg:py-12">
{/* Start (right in RTL): contact */}
<div className="text-start">
<h3 className="mb-4 text-base font-medium sm:text-lg">
اطلاعات تماس:
</h3>
<div className="space-y-2 text-sm font-light leading-relaxed sm:text-[15px]">
<p>
<span className="text-[#a6a6a6]">آدرس: </span>
تهران، خیابان ولیعصر بالاتر از پارک ساعی، پلاک ۲۴۸
</p>
<p>
<span className="text-[#a6a6a6]">تلفن فروشگاه: </span>
<span dir="ltr">021-22423600</span>
</p>
<p>
<span className="text-[#a6a6a6]">تلفن همراه: </span>
<span dir="ltr">09122504800</span>
</p>
<p>
<span className="text-[#a6a6a6]">آدرس ایمیل: </span>
<span dir="ltr">Info@ManaCollecitions.com</span>
</p>
</div>
</div>
{/* Store */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">فروشگاه</h3>
<ul className="space-y-2 text-sm font-light sm:text-[15px]">
{FOOTER_STORE_LINKS.map((link) => (
<li key={link.label}>
<Link href={link.href} className="hover:opacity-70">
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Categories */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">
دسته بندی محصولات
</h3>
<div className="grid grid-cols-2 gap-x-6 text-sm font-light text-[#e1e1e1] sm:text-[15px]">
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL1.map((label) => (
<li key={label}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL2.map((label) => (
<li key={`c2-${label}`}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* End (left in RTL): map */}
<div className="min-h-[180px] overflow-hidden rounded-[4px] bg-[#1a1a1a] sm:min-h-[200px]">
<iframe
title="موقعیت فروشگاه مانا کالکشن"
src="https://maps.google.com/maps?q=Tehran%20Valiasr%20Street%20above%20Saei%20Park%20No%20248&z=16&output=embed"
className="h-full min-h-[180px] w-full border-0 [filter:invert(92%)_hue-rotate(180deg)_brightness(0.9)_contrast(0.95)] sm:min-h-[200px]"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
</div>
</div>
<div className="bg-black text-white">
<div className="mx-auto flex max-w-[1920px] flex-col-reverse items-center justify-between gap-4 px-4 py-4 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="text-center text-xs font-light sm:text-start sm:text-sm">
تمامی حق و حقوق مادی معنوی وبسایت متعلق به شرکت مانا کالکشن می‌باشد.
۱۴۰۴-۱۴۰۵
</p>
<div className="relative h-8 w-[160px] sm:h-9 sm:w-[190px]">
<Image
src={paymentBadges}
alt="روش‌های پرداخت"
fill
className="object-contain object-end"
sizes="190px"
/>
</div>
</div>
</div>
</footer>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { NAV_LINKS } from "@/data/home";
import HeaderSearch from "./HeaderSearch";
import HeaderUserLink from "./HeaderUserLink";
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
type SiteHeaderProps = {
megaTabs?: MegaTabData[];
};
export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
const [scrolled, setScrolled] = useState(false);
const [megaOpen, setMegaOpen] = useState(false);
const closeTimer = useRef<number | null>(null);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 24);
setMegaOpen(false);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!megaOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMegaOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [megaOpen]);
const openMega = () => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
setMegaOpen(true);
};
const scheduleClose = () => {
if (closeTimer.current) window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setMegaOpen(false), 120);
};
return (
<header
className={`pointer-events-none z-50 transition-[padding,background-color] duration-300 ease-out ${
scrolled
? "fixed inset-x-0 top-0 bg-white px-0 pt-0 shadow-[0_1px_0_rgba(0,0,0,0.06)]"
: "absolute inset-x-0 top-0 px-0 pt-6 sm:pt-8 lg:pt-14"
}`}
>
<div
className={`relative transition-[padding] duration-300 ease-out ${
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
}`}
>
<div
className={`pointer-events-auto mx-auto flex items-center justify-between gap-3 transition-[height,border-radius,background-color,backdrop-filter,border-color,max-width,padding] duration-300 ease-out ${
scrolled
? "h-[56px] w-full max-w-none rounded-none border-0 bg-white px-4 sm:h-[64px] sm:px-6 lg:px-10"
: "h-[60px] max-w-[1709px] rounded-[5px] border border-white/20 bg-white/20 px-4 backdrop-blur-[25px] sm:h-[80px] sm:px-6 lg:px-8"
}`}
>
<Link
href="/"
aria-label="Mana Collection"
className={`flex shrink-0 items-center justify-center rounded-[2px] border border-ink transition-[width,height] duration-300 ${
scrolled ? "size-9 sm:size-10" : "size-11 sm:size-[52px]"
}`}
>
<span
className={`font-montserrat font-light leading-none transition-[font-size] duration-300 ${
scrolled
? "text-[20px] sm:text-[24px]"
: "text-[26px] sm:text-[32px]"
}`}
>
M
</span>
</Link>
<nav className="hidden items-center gap-5 lg:flex xl:gap-6">
{NAV_LINKS.map((link) => {
const isProducts = "hasDropdown" in link && link.hasDropdown;
if (isProducts) {
return (
<div
key={link.label}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
>
<button
type="button"
aria-expanded={megaOpen}
aria-haspopup="true"
aria-controls="products-mega-menu"
onClick={() => setMegaOpen((v) => !v)}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
<Image
src="/images/home/icons/caret-down.svg"
alt=""
width={16}
height={16}
unoptimized
className={`transition-transform duration-300 ${
megaOpen ? "rotate-180" : ""
}`}
/>
</button>
</div>
);
}
return (
<Link
key={link.label}
href={link.href}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
</Link>
);
})}
</nav>
<div dir="ltr" className="flex items-center gap-3.5 sm:gap-4">
<HeaderUserLink />
<Link
href="/cart"
aria-label="سبد خرید"
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/bag.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
<HeaderSearch />
</div>
</div>
<div
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
className={scrolled ? "" : "px-0"}
>
<ProductsMegaMenu
open={megaOpen}
onClose={() => setMegaOpen(false)}
tabs={megaTabs}
/>
</div>
</div>
</header>
);
}