From beac5f3b7302a737d5d22608224617024e4c562a Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 7 Oct 2026 18:26:46 +0330 Subject: [PATCH] Open the leather banner to the full viewport on scroll and align homepage frames. Co-authored-by: Cursor --- src/app/page.tsx | 24 +- src/components/home/CategoryBento.tsx | 139 +++++++++++ src/components/home/HeroSlider.tsx | 8 +- src/components/home/LeatherBanner.tsx | 87 ++++++- src/components/home/OverlayProductCard.tsx | 63 +++++ src/components/home/ProductCard.tsx | 57 ++++- src/components/home/ProductRow.tsx | 70 +++++- src/components/home/SiteHeader.tsx | 260 +++++++++------------ src/data/home.ts | 76 ++++++ src/lib/map-home-slots.ts | 9 +- src/lib/products-api.ts | 21 ++ 11 files changed, 621 insertions(+), 193 deletions(-) create mode 100644 src/components/home/CategoryBento.tsx create mode 100644 src/components/home/OverlayProductCard.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index d1ff7f6..245e111 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { - + {/* */} +
-
- + -
+ diff --git a/src/components/home/CategoryBento.tsx b/src/components/home/CategoryBento.tsx new file mode 100644 index 0000000..6cb6e59 --- /dev/null +++ b/src/components/home/CategoryBento.tsx @@ -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 ( +
+ + {category.nameFa + +
+ ); +} + +/** + * 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(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 ( +
+
+ {source.slice(0, 5).map((category, i) => ( + + ))} +
+ +
+
+ + + + + +
+
+
+ ); +} diff --git a/src/components/home/HeroSlider.tsx b/src/components/home/HeroSlider.tsx index b8ed2d7..f201ae7 100644 --- a/src/components/home/HeroSlider.tsx +++ b/src/components/home/HeroSlider.tsx @@ -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 (
setPaused(true)} onMouseLeave={() => setPaused(false)} > diff --git a/src/components/home/LeatherBanner.tsx b/src/components/home/LeatherBanner.tsx index 820e3e2..66215cf 100644 --- a/src/components/home/LeatherBanner.tsx +++ b/src/components/home/LeatherBanner.tsx @@ -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(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 ( -
-
+
+
{nameFa} +
-
+ {/* Outside the sticky frame so it keeps moving with the page. */} +
+
+ + {children ? ( +
{children}
+ ) : null}
); } diff --git a/src/components/home/OverlayProductCard.tsx b/src/components/home/OverlayProductCard.tsx new file mode 100644 index 0000000..8fc5d59 --- /dev/null +++ b/src/components/home/OverlayProductCard.tsx @@ -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 ( +
+ + {product.nameFa} + + + + + {product.nameFa} + + {product.nameEn ? ( + + {product.nameEn} + + ) : null} + + {description} + + + + +
+ ); +} diff --git a/src/components/home/ProductCard.tsx b/src/components/home/ProductCard.tsx index 3f9c925..8042a23 100644 --- a/src/components/home/ProductCard.tsx +++ b/src/components/home/ProductCard.tsx @@ -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 (
- + {product.nameFa} @@ -58,14 +65,48 @@ export default function ProductCard({ product, className = "" }: ProductCardProp - - - {product.price} + {hasVars ? ( + + {sizes.length > 0 ? ( + + {sizes.slice(0, 5).map((size) => ( + + {size.labelFa} + + ))} + + ) : null} + {colors.length > 0 ? ( + + {colors.slice(0, 6).map((color) => ( + + ))} + + ) : null} - IRT + ) : null} + + + + {/* Vars occupy ~1 line — clamp desc to 1 so the image height stays even */} + + {description} diff --git a/src/components/home/ProductRow.tsx b/src/components/home/ProductRow.tsx index 64ce814..f88e260 100644 --- a/src/components/home/ProductRow.tsx +++ b/src/components/home/ProductRow.tsx @@ -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(null); const trackRef = useRef(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 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 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 ( +
+ +
+ {gridProducts.map((product, i) => ( +
+ +
+ ))} +
+
+ ); + } return (
-
- {!scrolled ? ( - - ) : null} + <> +
+
+ +
- - - M - - +
+ ); + } + + return ( + + {link.label} + ); - } + })} + - return ( - - {link.label} - - ); - })} - - -
- - - - - +
+ +
+
+ +
+ + + + + +
+
+ +
+ setMegaOpen(false)} + tabs={megaTabs} + />
- -
- setMegaOpen(false)} - tabs={megaTabs} - /> -
-
- + + {/* Matches fixed header height so page content starts below it */} +
+ ); } diff --git a/src/data/home.ts b/src/data/home.ts index ef1505f..6d4390b 100644 --- a/src/data/home.ts +++ b/src/data/home.ts @@ -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" }, + ], }, ]; diff --git a/src/lib/map-home-slots.ts b/src/lib/map-home-slots.ts index c404290..c5cac49 100644 --- a/src/lib/map-home-slots.ts +++ b/src/lib/map-home-slots.ts @@ -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: { diff --git a/src/lib/products-api.ts b/src/lib/products-api.ts index cf8f801..2d95d3c 100644 --- a/src/lib/products-api.ts +++ b/src/lib/products-api.ts @@ -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, + })), }; }