Open the leather banner to the full viewport on scroll and align homepage frames.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-07 18:26:46 +03:30
co-authored by Cursor
parent f44058bd6e
commit beac5f3b73
11 changed files with 621 additions and 193 deletions
+13 -11
View File
@@ -1,6 +1,7 @@
import SiteHeader from "@/components/home/SiteHeader";
import HeroSlider from "@/components/home/HeroSlider";
import CategoryStrip from "@/components/home/CategoryStrip";
// import CategoryStrip from "@/components/home/CategoryStrip";
import CategoryBento from "@/components/home/CategoryBento";
import ProductRow from "@/components/home/ProductRow";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
@@ -54,7 +55,8 @@ export default async function HomePage() {
<HeroSlider slides={home.heroSlides} />
</div>
<CategoryStrip categories={categories} />
{/* <CategoryStrip categories={categories} /> */}
<CategoryBento categories={categories} />
<div className="flex flex-col gap-10 lg:gap-14">
<ProductRow
@@ -64,16 +66,15 @@ export default async function HomePage() {
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}
/>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
>
<PartnerCta />
</div>
</LeatherBanner>
<ProductRow
products={discountProducts}
@@ -86,6 +87,7 @@ export default async function HomePage() {
<ProductRow
products={autumnProducts}
promo="autumn"
layout="overlay-grid"
stagger
promoSlot={home.promoAutumn}
/>
+139
View File
@@ -0,0 +1,139 @@
"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 CategoryBentoProps = {
categories?: Category[];
};
function CategoryTile({
category,
className = "",
index,
visible,
}: {
category: Category;
className?: string;
index: number;
visible: boolean;
}) {
return (
<div
className={`seq-fade-item h-full min-h-0 ${className}`}
style={{
transitionDelay: visible ? `${index * 90}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}}
>
<Link
href={category.href ?? "#"}
aria-label={category.nameFa || category.nameEn}
className="group relative block h-full min-h-0 overflow-hidden rounded-[2px] bg-[#f2f2f2]"
>
<Image
src={category.image}
alt={category.nameFa || category.nameEn}
fill
sizes="(max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
/>
</Link>
</div>
);
}
/**
* Layout (width × height):
* 1×3 - 2×1
* 1×1 - 1×2
* 1×2 - 1×1 - 1×1
*/
export default function CategoryBento({ categories }: CategoryBentoProps) {
const source = categories?.length ? categories : CATEGORIES;
const at = (i: number) => source[i % source.length]!;
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="mx-auto w-full max-w-[1600px] px-4 pt-12 pb-6 sm:px-6 sm:pt-16 sm:pb-8 lg:px-10 lg:pt-20 lg:pb-10"
>
<div className="grid grid-cols-2 gap-1.5 sm:gap-2 lg:hidden">
{source.slice(0, 5).map((category, i) => (
<CategoryTile
key={category.id}
category={category}
className="aspect-[4/5]"
index={i}
visible={visible}
/>
))}
</div>
<div className="hidden lg:block">
<div
className="grid grid-cols-3 grid-rows-3 gap-1.5 sm:gap-2"
style={{ height: "min(62vw, 560px)" }}
>
<CategoryTile
category={at(0)}
className="col-start-1 row-span-3 row-start-1"
index={0}
visible={visible}
/>
<CategoryTile
category={at(1)}
className="col-span-2 col-start-2 row-start-1"
index={1}
visible={visible}
/>
<CategoryTile
category={at(2)}
className="col-start-2 row-start-2"
index={2}
visible={visible}
/>
<CategoryTile
category={at(3)}
className="col-start-3 row-span-2 row-start-2"
index={3}
visible={visible}
/>
<CategoryTile
category={at(4)}
className="col-start-2 row-start-3"
index={4}
visible={visible}
/>
</div>
</div>
</section>
);
}
+4 -4
View File
@@ -17,7 +17,7 @@ const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "autumn",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_20%]",
imagePosition: "object-[center_top]",
title: "برای بانویی که خودش انتخاب میکند.",
subtitle: "made for her",
cta: { label: "کالکشن پاییزه", href: "/products?collection=autumn" },
@@ -25,7 +25,7 @@ const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "new",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_40%]",
imagePosition: "object-[center_top]",
title: "جدیدترین‌های فصل همین حالا ببینید.",
subtitle: "new arrivals",
cta: { label: "جدیدترین‌ها", href: "/products?sort=newest" },
@@ -33,7 +33,7 @@ const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "sale",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_55%]",
imagePosition: "object-[center_top]",
title: "فروش ویژه تا ۷۰٪ تخفیف",
subtitle: "up to 70% off",
cta: { label: "مشاهده تخفیف‌ها", href: "/products?sale=1" },
@@ -83,7 +83,7 @@ export default function HeroSlider({ slides }: HeroSliderProps) {
return (
<section
className="relative h-[min(100dvh,1081px)] min-h-[560px] w-full overflow-hidden bg-[#ebe6df]"
className="relative h-[calc(100dvh-7.5rem)] min-h-[480px] w-full overflow-hidden bg-[#ebe6df] sm:h-[calc(100dvh-8.25rem)]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
+82 -5
View File
@@ -1,4 +1,13 @@
"use client";
import Image from "next/image";
import {
type ReactNode,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import HoverFillButton from "./HoverFillButton";
type LeatherBannerProps = {
@@ -7,19 +16,29 @@ type LeatherBannerProps = {
nameEn?: string;
linkText?: string;
link?: string;
children?: ReactNode;
};
function bannerHeight(width: number) {
return Math.min(Math.max(width, 0), 1920) * (585 / 1920);
}
export default function LeatherBanner({
image = "/images/home/banners/leather.png",
nameFa = "هنر پوشیدن",
nameEn = "THE ART OF DRESSING",
linkText = "کالکشن چرم",
link = "/products?collection=leather",
children,
}: LeatherBannerProps) {
const trackRef = useRef<HTMLElement>(null);
const [bannerH, setBannerH] = useState(0);
const [vh, setVh] = useState(0);
const [progress, setProgress] = useState(0);
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(" ")
? (() => {
@@ -29,18 +48,72 @@ export default function LeatherBanner({
})()
: [line1, line2 || "OF DRESSING"];
const update = useEffectEvent(() => {
const track = trackRef.current;
if (!track) return;
const nextVh = window.innerHeight;
const nextBannerH = bannerHeight(track.clientWidth);
setVh(nextVh);
setBannerH(nextBannerH);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setProgress(0);
return;
}
const rect = track.getBoundingClientRect();
const grow = Math.max(1, nextVh - nextBannerH);
// 0 when the frame sits on the viewport bottom, 1 when it fills the screen.
const start = nextVh - nextBannerH;
const scrolled = Math.min(grow, Math.max(0, start - rect.top));
setProgress(scrolled / grow);
});
useEffect(() => {
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, []);
const frameH =
bannerH > 0 ? bannerH + progress * Math.max(0, vh - bannerH) : 0;
// Subject travels as the frame opens.
const imageY = 78 - progress * 48;
return (
<section className="relative w-full overflow-hidden bg-white">
<div className="relative mx-auto w-full max-w-[1920px] aspect-[1920/585]">
<section ref={trackRef} className="relative h-[100dvh] w-full">
<div
className="sticky z-10 w-full overflow-hidden bg-[#ebe6df]"
style={{
height: frameH || undefined,
top: frameH ? `calc(100dvh - ${frameH}px)` : undefined,
aspectRatio: frameH ? undefined : "1920 / 585",
}}
>
<Image
src={image}
alt={nameFa}
fill
sizes="100vw"
className="object-cover object-left-bottom"
className="object-cover"
style={{ objectPosition: `left ${imageY}%` }}
/>
</div>
<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))]">
{/* Outside the sticky frame so it keeps moving with the page. */}
<div
className="pointer-events-none absolute inset-x-0 top-0 z-20"
style={{
height: bannerH || undefined,
aspectRatio: bannerH ? undefined : "1920 / 585",
}}
>
<div className="pointer-events-auto absolute inset-y-0 right-4 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"
@@ -70,6 +143,10 @@ export default function LeatherBanner({
</div>
</div>
</div>
{children ? (
<div className="absolute inset-x-0 bottom-0 z-30">{children}</div>
) : null}
</section>
);
}
@@ -0,0 +1,63 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { Product } from "@/data/home";
import { productPath } from "@/lib/product-path";
const FALLBACK_DESC =
"لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.";
type OverlayProductCardProps = {
product: Product;
className?: string;
};
export default function OverlayProductCard({
product,
className = "",
}: OverlayProductCardProps) {
const href = product.href ?? productPath(product);
const description = product.description?.trim() || FALLBACK_DESC;
return (
<article
className={`group relative aspect-[3/4] w-full overflow-hidden rounded-[5px] border border-ink/15 bg-white p-2 transition-colors duration-300 ease-out hover:border-ink/40 sm:p-2.5 ${className}`}
>
<Link
href={href}
className="relative block h-full w-full overflow-hidden rounded-[3px]"
aria-label={product.nameFa}
>
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover object-top transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
<span
className="absolute inset-x-0 bottom-0 z-10 translate-y-full bg-ink px-3 py-3 transition-transform duration-[400ms] ease-out group-hover:translate-y-0 sm:px-3.5 sm:py-3.5"
>
<span className="block text-right">
<span className="line-clamp-1 block text-[13px] font-medium text-white sm:text-[15px]">
{product.nameFa}
</span>
{product.nameEn ? (
<span
dir="ltr"
className="mt-0.5 line-clamp-1 block w-full text-right font-montserrat text-[8px] font-light uppercase tracking-[0.2em] text-white/50 sm:text-[9px]"
>
{product.nameEn}
</span>
) : null}
<span className="mt-1.5 line-clamp-2 block text-[10px] font-light leading-relaxed text-white/70 sm:text-[11px]">
{description}
</span>
</span>
</span>
</Link>
</article>
);
}
+49 -8
View File
@@ -5,6 +5,9 @@ import Link from "next/link";
import type { Product } from "@/data/home";
import { productPath } from "@/lib/product-path";
const FALLBACK_DESC =
"لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.";
type ProductCardProps = {
product: Product;
className?: string;
@@ -13,6 +16,10 @@ type ProductCardProps = {
export default function ProductCard({ product, className = "" }: ProductCardProps) {
const href = product.href ?? productPath(product);
const hoverSrc = product.imageHover;
const colors = product.colors ?? [];
const sizes = product.sizes ?? [];
const hasVars = colors.length > 0 || sizes.length > 0;
const description = product.description?.trim() || FALLBACK_DESC;
return (
<article
@@ -45,7 +52,7 @@ export default function ProductCard({ product, className = "" }: ProductCardProp
</span>
</span>
<span className="flex shrink-0 flex-col gap-2 px-3.5 pb-3.5 pt-1">
<span className="flex shrink-0 flex-col gap-1.5 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}
@@ -58,14 +65,48 @@ export default function ProductCard({ product, className = "" }: ProductCardProp
</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}
{hasVars ? (
<span className="flex flex-wrap items-center justify-start gap-2">
{sizes.length > 0 ? (
<span className="flex items-center gap-1" aria-label="سایزها" dir="ltr">
{sizes.slice(0, 5).map((size) => (
<span
key={size.id}
title={size.labelFa}
className="inline-flex h-5 min-w-5 items-center justify-center rounded-[3px] border border-ink/20 px-1 font-montserrat text-[9px] font-medium uppercase leading-none text-ink"
>
{size.labelFa}
</span>
))}
</span>
) : null}
{colors.length > 0 ? (
<span className="flex items-center gap-1" aria-label="رنگ‌های موجود">
{colors.slice(0, 6).map((color) => (
<span
key={color.id}
title={color.labelFa}
className="inline-block size-3.5 shrink-0 rounded-full border border-black/10"
style={{ background: color.swatch || "#ccc" }}
/>
))}
</span>
) : null}
</span>
<span className="text-[9px] leading-none text-[#878787]">IRT</span>
) : null}
<span
aria-hidden
className="mt-0.5 block h-px w-full bg-ink/10 transition-colors duration-300 ease-out group-hover:bg-ink/35"
/>
{/* Vars occupy ~1 line — clamp desc to 1 so the image height stays even */}
<span
className={`${
hasVars ? "line-clamp-1" : "line-clamp-2"
} text-right text-[11px] font-light leading-relaxed text-[#6e6e6e]`}
>
{description}
</span>
</span>
</Link>
+64 -6
View File
@@ -3,7 +3,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import BilingualTitle from "@/components/ui/BilingualTitle";
import ProductCard from "./ProductCard";
import OverlayProductCard from "./OverlayProductCard";
import PromoCard, { type PromoSlotOverride } from "./PromoCard";
type ProductRowProps = {
@@ -13,10 +15,23 @@ type ProductRowProps = {
/** Stagger fade-in for promo + product cards when scrolled into view */
stagger?: boolean;
promoSlot?: PromoSlotOverride | null;
/**
* `carousel` — promo + horizontal product scroller (default)
* `overlay-grid` — 2×4 image-only cards; black hover overlay with name + desc
*/
layout?: "carousel" | "overlay-grid";
};
const CARD_W =
"w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
const OVERLAY_GRID_TITLES: Record<
ProductRowProps["promo"],
{ titleFa: string; titleEn: string }
> = {
sale: { titleFa: "فروش ویژه", titleEn: "Sale" },
discount: { titleFa: "پیشنهادهای ویژه", titleEn: "Special offers" },
autumn: { titleFa: "کالکشن پاییزه", titleEn: "Autumn collection" },
};
const CARD_W = "w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
export default function ProductRow({
products,
@@ -24,6 +39,7 @@ export default function ProductRow({
className = "",
stagger = false,
promoSlot,
layout = "carousel",
}: ProductRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -66,6 +82,7 @@ export default function ProductRow({
}, [applyOffset]);
useEffect(() => {
if (layout !== "carousel") return;
measure();
const viewport = viewportRef.current;
if (!viewport) return;
@@ -79,7 +96,7 @@ export default function ProductRow({
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products, promo]);
}, [measure, products, promo, layout]);
useEffect(() => {
if (!stagger) return;
@@ -108,13 +125,13 @@ export default function ProductRow({
};
useEffect(() => {
if (layout !== "carousel") return;
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 = {
@@ -131,7 +148,6 @@ export default function ProductRow({
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);
@@ -179,7 +195,49 @@ export default function ProductRow({
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
};
}, [applyOffset]);
}, [applyOffset, layout]);
if (layout === "overlay-grid") {
const gridProducts = products.slice(0, 8);
const title = OVERLAY_GRID_TITLES[promo];
return (
<section
ref={sectionRef}
dir="rtl"
className={`mx-auto w-full max-w-[1600px] px-4 sm:px-6 lg:px-10 ${className}`}
>
<BilingualTitle
titleFa={promoSlot?.nameFa?.trim() || title.titleFa}
titleEn={promoSlot?.nameEn?.trim() || title.titleEn}
align="center"
size="lg"
className="mb-6 sm:mb-8"
/>
<div className="grid grid-cols-2 gap-2 sm:gap-2.5 md:grid-cols-4 lg:gap-3">
{gridProducts.map((product, i) => (
<div
key={`${promo}-overlay-${product.id}-${i}`}
className="seq-fade-item"
style={
stagger
? {
transitionDelay: visible ? `${i * 70}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<OverlayProductCard product={product} />
</div>
))}
</div>
</section>
);
}
return (
<section
+108 -152
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { NAV_LINKS } from "@/data/home";
import { CONTACT_PAGE } from "@/data/pages";
import ManaLogo from "@/components/ui/ManaLogo";
import HeaderSearch from "./HeaderSearch";
import HeaderUserLink from "./HeaderUserLink";
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
@@ -51,169 +51,125 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
};
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-3 sm:pt-4 lg:pt-5"
}`}
>
<div
className={`relative transition-[padding] duration-300 ease-out ${
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
}`}
>
{!scrolled ? (
<div
dir="rtl"
className="pointer-events-auto mx-auto mb-1.5 flex max-w-[1709px] items-center justify-between gap-4 font-montserrat text-sm font-light text-ink sm:mb-2 sm:text-base"
>
<a
href={`tel:${CONTACT_PAGE.phoneStore.replace(/-/g, "")}`}
dir="ltr"
className="inline-flex items-center gap-1.5 transition-opacity hover:opacity-70"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden
className="opacity-70"
>
<path
d="M8.5 4.5h3.2l1 3.2-1.8 1.1a11 11 0 0 0 4.3 4.3l1.1-1.8 3.2 1v3.2a2 2 0 0 1-2.1 2A14.5 14.5 0 0 1 4.5 6.6 2 2 0 0 1 6.5 4.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
<span>{CONTACT_PAGE.phoneStore}</span>
</a>
<a
href={CONTACT_PAGE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="inline-flex items-center gap-1.5 transition-opacity hover:opacity-70"
>
<Image
src="/images/home/social/instagram.svg"
alt=""
width={16}
height={16}
unoptimized
className="opacity-70"
/>
<span>{CONTACT_PAGE.instagram}</span>
</a>
</div>
) : null}
<>
<header className="fixed inset-x-0 top-0 z-50">
<div
className={`flex w-full items-center justify-center overflow-hidden bg-black transition-[height,opacity,padding] duration-300 ease-out ${
scrolled
? "h-0 py-0 opacity-0"
: "h-[4.75rem] py-1 opacity-100 sm:h-[5.25rem]"
}`}
aria-hidden={scrolled}
>
<ManaLogo href="/" variant="white" height={72} priority />
</div>
<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 ${
className={`relative box-border w-full border-b border-black/5 bg-white transition-[height,box-shadow] 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"
? "h-16 shadow-[0_1px_0_rgba(0,0,0,0.06)] sm:h-[4.5rem]"
: "h-11 sm:h-12"
}`}
>
<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]"
}`}
<div
dir="rtl"
className="relative mx-auto flex h-full max-w-[1709px] items-center justify-between gap-3 px-4 sm:px-6 lg:px-10"
>
<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="z-10 hidden items-center gap-5 lg:flex xl:gap-6">
{NAV_LINKS.map((link) => {
const isProducts = "hasDropdown" in link && link.hasDropdown;
<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]"
if (isProducts) {
return (
<div
key={link.label}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
>
{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>
<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>
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"
<div
className={`pointer-events-none absolute inset-0 z-[5] flex items-center justify-center transition-opacity duration-300 ease-out ${
scrolled ? "opacity-100" : "opacity-0"
}`}
aria-hidden={!scrolled}
>
<Image
src="/images/home/icons/bag.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
<HeaderSearch />
<div className="pointer-events-auto">
<ManaLogo href="/" variant="black" height={44} />
</div>
</div>
<div dir="ltr" className="z-10 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
className="h-5 w-auto"
/>
</Link>
<HeaderSearch />
</div>
</div>
<div onMouseEnter={openMega} onMouseLeave={scheduleClose}>
<ProductsMegaMenu
open={megaOpen}
onClose={() => setMegaOpen(false)}
tabs={megaTabs}
/>
</div>
</div>
<div
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
className={scrolled ? "" : "px-0"}
>
<ProductsMegaMenu
open={megaOpen}
onClose={() => setMegaOpen(false)}
tabs={megaTabs}
/>
</div>
</div>
</header>
</header>
{/* Matches fixed header height so page content starts below it */}
<div
className={`shrink-0 transition-[height] duration-300 ease-out ${
scrolled ? "h-16 sm:h-[4.5rem]" : "h-[7.5rem] sm:h-[8.25rem]"
}`}
aria-hidden
/>
</>
);
}
+76
View File
@@ -1,5 +1,16 @@
import { categoryPath } from "@/lib/category-path";
export type ProductColorOption = {
id: string;
labelFa: string;
swatch?: string;
};
export type ProductSizeOption = {
id: string;
labelFa: string;
};
export type Product = {
id: string | number;
nameFa: string;
@@ -9,6 +20,10 @@ export type Product = {
/** Second photo revealed on card hover (slides in from the left). */
imageHover?: string;
href?: string;
/** Short card description (2 lines); falls back to لورم ایپسوم */
description?: string;
colors?: ProductColorOption[];
sizes?: ProductSizeOption[];
};
export type Category = {
@@ -218,6 +233,17 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-beige.jpg",
colors: [
{ id: "blue", labelFa: "آبی", swatch: "#2f4a6e" },
{ id: "beige", labelFa: "بژ", swatch: "#c4a882" },
{ id: "navy", labelFa: "سرمه‌ای", swatch: "#1b2a4a" },
],
sizes: [
{ id: "s", labelFa: "S" },
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
{ id: "xl", labelFa: "XL" },
],
},
{
id: "p2",
@@ -226,6 +252,16 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
colors: [
{ id: "beige", labelFa: "بژ", swatch: "#c4a882" },
{ id: "cream", labelFa: "کرم", swatch: "#e8dcc8" },
{ id: "black", labelFa: "مشکی", swatch: "#0a0a0a" },
],
sizes: [
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
{ id: "xl", labelFa: "XL" },
],
},
{
id: "p3",
@@ -234,6 +270,17 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-navy.jpg",
imageHover: "/images/home/products/coat-check.jpg",
colors: [
{ id: "navy", labelFa: "سرمه‌ای", swatch: "#1b2a4a" },
{ id: "black", labelFa: "مشکی", swatch: "#0a0a0a" },
],
sizes: [
{ id: "s", labelFa: "S" },
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
{ id: "xl", labelFa: "XL" },
{ id: "xxl", labelFa: "XXL" },
],
},
{
id: "p4",
@@ -242,6 +289,15 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-check.jpg",
imageHover: "/images/home/products/coat-blue.jpg",
colors: [
{ id: "check", labelFa: "چهارخانه", swatch: "#6b6358" },
{ id: "beige", labelFa: "بژ", swatch: "#c4a882" },
],
sizes: [
{ id: "s", labelFa: "S" },
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
],
},
{
id: "p5",
@@ -250,6 +306,16 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
colors: [
{ id: "blue", labelFa: "آبی", swatch: "#2f4a6e" },
{ id: "navy", labelFa: "سرمه‌ای", swatch: "#1b2a4a" },
{ id: "gray", labelFa: "طوسی", swatch: "#8a8a8a" },
],
sizes: [
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
{ id: "xl", labelFa: "XL" },
],
},
{
id: "p6",
@@ -258,6 +324,16 @@ export const SALE_PRODUCTS: Product[] = [
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-check.jpg",
colors: [
{ id: "beige", labelFa: "بژ", swatch: "#c4a882" },
{ id: "cream", labelFa: "کرم", swatch: "#e8dcc8" },
],
sizes: [
{ id: "s", labelFa: "S" },
{ id: "m", labelFa: "M" },
{ id: "l", labelFa: "L" },
{ id: "xl", labelFa: "XL" },
],
},
];
+2 -7
View File
@@ -102,15 +102,10 @@ export function mapHomeSlots(
const heroItems = slotItems(items, "hero-slider", "home.hero");
const heroSlides: HeroSlideData[] =
heroItems.length > 0
? heroItems.map((item, i) => ({
? heroItems.map((item) => ({
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%]",
imagePosition: "object-[center_top]",
title: item.nameFa || item.nameEn || "مانا کالکشن",
subtitle: item.nameEn || item.subtext || "",
cta: {
+21
View File
@@ -53,6 +53,17 @@ 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) ?? [];
const variantColors = (p.variants ?? []).map((v) => v.colorId).filter(Boolean);
const variantSizes = (p.variants ?? []).map((v) => v.sizeId).filter(Boolean);
const colors = uniqueOptions(
[...(p.category?.colors ?? []), ...variantColors],
"color",
);
const sizes = uniqueOptions(
[...(p.category?.sizes ?? []), ...variantSizes],
"size",
);
const description = (p.summary || p.description || "").trim() || undefined;
return {
id: p.id,
nameFa: p.nameFa || "بدون نام",
@@ -61,6 +72,16 @@ export function mapApiProductToCard(p: ApiProduct): Product {
image: p.imageUrl || gallery[0] || FALLBACK_IMAGE,
imageHover: gallery[1] || gallery[0] || p.imageUrl || undefined,
href: productPath(p),
description,
colors: colors.map((c) => ({
id: c.id,
labelFa: c.labelFa,
swatch: c.swatch,
})),
sizes: sizes.map((s) => ({
id: s.id,
labelFa: s.labelFa,
})),
};
}