Improve mobile storefront layout, navigation, and product browsing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b3df0d3eb4
commit
4e4dd731b2
@@ -20,7 +20,7 @@ export default function AboutSection({
|
||||
imageSecondary = ABOUT.imageSecondary,
|
||||
}: AboutSectionProps) {
|
||||
return (
|
||||
<section id="about" dir="rtl" className="scroll-mt-24 bg-white py-16 lg:py-24">
|
||||
<section id="about" dir="rtl" className="scroll-mt-24 bg-background 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
|
||||
@@ -64,7 +64,7 @@ export default function AboutSection({
|
||||
<p className="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">
|
||||
<p className="mt-4 text-justify text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,105 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CATEGORIES, type Category } from "@/data/home";
|
||||
import { type Category } from "@/data/home";
|
||||
import { categoryPath } from "@/lib/category-path";
|
||||
|
||||
type TileLayout = "stack" | "beside";
|
||||
|
||||
type BentoTile = {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
image: string;
|
||||
href: string;
|
||||
layout: TileLayout;
|
||||
className: string;
|
||||
};
|
||||
|
||||
/** Fixed bento art + layout; href/names can be overridden from API categories. */
|
||||
const BENTO_TILES: BentoTile[] = [
|
||||
{
|
||||
id: "coat",
|
||||
nameFa: "مانتو",
|
||||
nameEn: "Mantel",
|
||||
image: "/images/transparent/mantel.png",
|
||||
href: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" }),
|
||||
layout: "stack",
|
||||
className: "col-span-2 col-start-1 row-span-2 row-start-1",
|
||||
},
|
||||
{
|
||||
id: "shirts",
|
||||
nameFa: "پیراهن",
|
||||
nameEn: "Shirts",
|
||||
image: "/images/transparent/shirt.png",
|
||||
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
|
||||
layout: "beside",
|
||||
className: "col-span-4 col-start-3 row-start-1",
|
||||
},
|
||||
{
|
||||
id: "pants",
|
||||
nameFa: "شلوار",
|
||||
nameEn: "Pants",
|
||||
image: "/images/transparent/pants.png",
|
||||
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
|
||||
layout: "beside",
|
||||
className: "col-span-2 col-start-3 row-start-2",
|
||||
},
|
||||
{
|
||||
id: "bags",
|
||||
nameFa: "کیف زنانه",
|
||||
nameEn: "Wemon Bag",
|
||||
image: "/images/transparent/bag.png",
|
||||
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
|
||||
layout: "beside",
|
||||
className: "col-span-2 col-start-5 row-start-2",
|
||||
},
|
||||
{
|
||||
id: "scarf",
|
||||
nameFa: "شال",
|
||||
nameEn: "Scarf",
|
||||
image: "/images/transparent/scarf.png",
|
||||
href: categoryPath({ id: "scarf", nameEn: "Scarf", nameFa: "شال" }),
|
||||
layout: "beside",
|
||||
className: "col-span-3 col-start-1 row-start-3",
|
||||
},
|
||||
{
|
||||
id: "shoes",
|
||||
nameFa: "کفش",
|
||||
nameEn: "Shoe",
|
||||
image: "/images/transparent/shoe.png",
|
||||
href: categoryPath({ id: "shoes", nameEn: "Shoe", nameFa: "کفش" }),
|
||||
layout: "beside",
|
||||
className: "col-span-3 col-start-4 row-start-3",
|
||||
},
|
||||
];
|
||||
|
||||
type CategoryBentoProps = {
|
||||
categories?: Category[];
|
||||
};
|
||||
|
||||
const TITLE_FA_WORDS = ["دسته", "بندی", "محصولات", "ما"] as const;
|
||||
|
||||
/** Match PNG proportions so the box hugs the product (no huge empty sides). */
|
||||
function imageAspect(id: string): string {
|
||||
if (id === "shoes" || id === "scarf") return "1 / 1";
|
||||
if (id === "coat") return "9 / 16";
|
||||
return "2 / 3";
|
||||
}
|
||||
|
||||
function CategoryTile({
|
||||
category,
|
||||
tile,
|
||||
className = "",
|
||||
index,
|
||||
visible,
|
||||
}: {
|
||||
category: Category;
|
||||
tile: BentoTile;
|
||||
className?: string;
|
||||
index: number;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const isStack = tile.layout === "stack";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`seq-fade-item h-full min-h-0 ${className}`}
|
||||
@@ -32,31 +114,87 @@ function CategoryTile({
|
||||
}}
|
||||
>
|
||||
<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]"
|
||||
href={tile.href}
|
||||
aria-label={tile.nameFa || tile.nameEn}
|
||||
className="group relative flex h-full min-h-0 items-center justify-center overflow-hidden rounded-[2px] bg-[#ececec] p-1 sm:p-1.5"
|
||||
>
|
||||
<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]"
|
||||
/>
|
||||
{/* Tight cluster: image width follows aspect ratio, label sits against it */}
|
||||
<div
|
||||
className={`flex h-full max-h-full max-w-full ${
|
||||
isStack
|
||||
? "flex-col items-center justify-center gap-0.5"
|
||||
: "flex-col items-center justify-center gap-0.5 lg:flex-row lg:items-stretch lg:justify-center lg:gap-2 lg:py-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative shrink-0 transition-transform duration-500 ease-out group-hover:scale-[1.04] ${
|
||||
tile.id === "shoes" || tile.id === "scarf"
|
||||
? // Full-width mobile row → height-led; tall half-cell → width-led; desktop row → height-led
|
||||
"h-[88%] w-auto sm:h-auto sm:w-[82%] sm:max-h-[74%] lg:h-full lg:w-auto lg:max-h-full"
|
||||
: isStack
|
||||
? "h-[92%]"
|
||||
: "h-[90%] lg:h-full"
|
||||
}`}
|
||||
style={{ aspectRatio: imageAspect(tile.id) }}
|
||||
>
|
||||
<Image
|
||||
src={tile.image}
|
||||
alt={tile.nameFa || tile.nameEn}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 36vw"
|
||||
className="object-contain object-center"
|
||||
// Keep true RGBA alpha — Next's optimizer can palette-quantize
|
||||
// soft studio edges into opaque pale "plates".
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 self-center text-center">
|
||||
<p className="font-doran text-base font-medium leading-none text-ink sm:text-lg lg:text-[24px]">
|
||||
{tile.nameFa}
|
||||
</p>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-0.5 font-montserrat text-[9px] font-light uppercase leading-none tracking-[0.18em] text-[#8a8a8a] sm:text-[10px]"
|
||||
>
|
||||
{tile.nameEn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout (width × height):
|
||||
* 1×3 - 2×1
|
||||
* 1×1 - 1×2
|
||||
* 1×2 - 1×1 - 1×1
|
||||
* Desktop grid is 6×3 equal cells inside a 2:1 frame.
|
||||
* Mantel covers the top two rows. Scarf and shoe share the full bottom row.
|
||||
*
|
||||
* Tile aspect ratios (width:height) for image design:
|
||||
* - Mantel stack (2×2 of 6): 1:1 → 800×800
|
||||
* - Wide beside (4×1 of 6): 4:1 → 1600×400
|
||||
* - Short beside (2×1 of 6): 2:1 → 800×400
|
||||
* - Bottom row (3×1 of 6): 3:1 → 1200×400
|
||||
*
|
||||
* Six tiles: mantel, shirts, pants, bag, scarf, shoe.
|
||||
*/
|
||||
export default function CategoryBento({ categories }: CategoryBentoProps) {
|
||||
const source = categories?.length ? categories : CATEGORIES;
|
||||
const at = (i: number) => source[i % source.length]!;
|
||||
const tiles = BENTO_TILES.map((tile, index) => {
|
||||
const match =
|
||||
categories?.find((c) => c.id === tile.id) ?? categories?.[index];
|
||||
if (!match) return tile;
|
||||
const slotImage = match.image?.includes("/images/transparent/")
|
||||
? match.image
|
||||
: null;
|
||||
return {
|
||||
...tile,
|
||||
nameFa: match.nameFa || tile.nameFa,
|
||||
nameEn: match.nameEn || tile.nameEn,
|
||||
href: match.href || tile.href,
|
||||
image: slotImage || tile.image,
|
||||
};
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -67,16 +205,24 @@ export default function CategoryBento({ categories }: CategoryBentoProps) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const reveal = () => setVisible(true);
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
reveal();
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
|
||||
{ threshold: 0.08, rootMargin: "0px 0px -4% 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
// Sticky story frames can delay the first IO callback; catch already-in-view.
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vh = window.innerHeight || 0;
|
||||
if (rect.top < vh * 0.92 && rect.bottom > vh * 0.08) {
|
||||
reveal();
|
||||
io.disconnect();
|
||||
}
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -85,53 +231,72 @@ export default function CategoryBento({ categories }: CategoryBentoProps) {
|
||||
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="mb-6 text-center sm:mb-8 lg:mb-10">
|
||||
<h2 className="flex flex-wrap items-baseline justify-center gap-x-2 font-doran text-[clamp(1.5rem,2.5vw,2rem)] font-medium leading-tight text-ink">
|
||||
{TITLE_FA_WORDS.map((word, i) => (
|
||||
<span
|
||||
key={`${word}-${i}`}
|
||||
className="seq-fade-item inline-block"
|
||||
style={{
|
||||
transitionDelay: visible ? `${i * 90}ms` : "0ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible
|
||||
? "translate3d(0,0,0)"
|
||||
: "translate3d(0,18px,0)",
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="seq-fade-item mt-2 font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted sm:text-[13px]"
|
||||
style={{
|
||||
transitionDelay: visible
|
||||
? `${TITLE_FA_WORDS.length * 90}ms`
|
||||
: "0ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible
|
||||
? "translate3d(0,0,0)"
|
||||
: "translate3d(0,14px,0)",
|
||||
}}
|
||||
>
|
||||
Our Product Categories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
{tiles.map((tile, i) => {
|
||||
const isWideMobile =
|
||||
tile.id === "shoes" || tile.id === "scarf";
|
||||
return (
|
||||
<CategoryTile
|
||||
key={tile.id}
|
||||
tile={tile}
|
||||
className={
|
||||
isWideMobile
|
||||
? "col-span-2 aspect-[2/1] sm:col-span-1 sm:aspect-[3/4]"
|
||||
: "aspect-[3/4]"
|
||||
}
|
||||
index={TITLE_FA_WORDS.length + 1 + 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 className="grid aspect-[2/1] w-full grid-cols-6 grid-rows-3 gap-1.5 sm:gap-2">
|
||||
{tiles.map((tile, i) => (
|
||||
<CategoryTile
|
||||
key={tile.id}
|
||||
tile={tile}
|
||||
className={tile.className}
|
||||
index={TITLE_FA_WORDS.length + 1 + i}
|
||||
visible={visible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function HeroSlider({ slides }: HeroSliderProps) {
|
||||
) : null}
|
||||
{slide.cta.label ? (
|
||||
<div className="hero-copy-item hero-copy-item-3">
|
||||
<HoverFillButton href={slide.cta.href || "#"} className="mt-7 w-full">
|
||||
<HoverFillButton href={slide.cta.href || "#"} className="mt-7 !w-full">
|
||||
{slide.cta.label}
|
||||
</HoverFillButton>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function HoverFillButton({
|
||||
const isDark = variant === "dark";
|
||||
const isExternal = /^https?:\/\//i.test(href);
|
||||
|
||||
const classNames = `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] ${
|
||||
const classNames = `group relative inline-flex h-9 w-[11.5rem] max-w-full items-center justify-between gap-0 overflow-hidden rounded-[4px] border px-3 backdrop-blur-sm sm:h-10 sm:w-[12.5rem] sm:px-3.5 ${
|
||||
isDark
|
||||
? "border-white bg-transparent text-white"
|
||||
: "border-ink bg-white/30 text-ink"
|
||||
@@ -34,7 +34,7 @@ export default function HoverFillButton({
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative z-10 text-base font-light transition-colors duration-[400ms] ease-out sm:text-xl ${
|
||||
className={`relative z-10 shrink-0 text-[13px] font-light transition-colors duration-[400ms] ease-out sm:text-sm ${
|
||||
isDark ? "group-hover:text-ink" : "group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
@@ -47,10 +47,10 @@ export default function HoverFillButton({
|
||||
: "/images/home/icons/caret-left-dark.svg"
|
||||
}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
width={16}
|
||||
height={16}
|
||||
unoptimized
|
||||
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert"
|
||||
className="relative z-10 size-3.5 shrink-0 transition-[filter] duration-[400ms] ease-out group-hover:invert sm:size-4"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import HoverFillButton from "./HoverFillButton";
|
||||
|
||||
type LeatherBannerProps = {
|
||||
@@ -19,10 +11,6 @@ type LeatherBannerProps = {
|
||||
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 = "هنر پوشیدن",
|
||||
@@ -31,11 +19,6 @@ export default function LeatherBanner({
|
||||
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");
|
||||
@@ -48,105 +31,70 @@ 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;
|
||||
const copy = (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="flex w-full max-w-[20rem] flex-col items-stretch text-left text-ink sm:max-w-[min(100%,22rem)] md:w-[min(100%,16.5rem)] md:max-w-[min(100%,42vw)]"
|
||||
>
|
||||
<p className="whitespace-nowrap font-doran text-[clamp(1.75rem,8vw,4.5rem)] font-extrabold leading-none md:text-[clamp(1.35rem,4vw,4.5rem)]">
|
||||
{display1}
|
||||
</p>
|
||||
{display2 ? (
|
||||
<p className="mt-1 whitespace-nowrap font-doran text-[clamp(1.75rem,8vw,4.5rem)] font-extrabold leading-none md:text-[clamp(1.35rem,4vw,4.5rem)]">
|
||||
{display2}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
dir="rtl"
|
||||
className="mt-3 whitespace-nowrap font-doran text-[clamp(1.25rem,5.5vw,3.5rem)] font-light leading-none md:text-[clamp(1.1rem,3.2vw,3.5rem)]"
|
||||
>
|
||||
{nameFa}
|
||||
</p>
|
||||
{linkText ? (
|
||||
<HoverFillButton
|
||||
href={link || "#"}
|
||||
className="mt-5 !h-11 !w-full sm:!h-12 md:mt-4"
|
||||
>
|
||||
{linkText}
|
||||
</HoverFillButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<section className="relative w-full bg-[#fdfcf8]">
|
||||
{/* Mobile: text first, taller crop pinned to the subject on the left */}
|
||||
<div className="flex flex-col md:hidden">
|
||||
<div className="flex justify-center bg-[#fdfcf8] px-4 py-10 sm:px-6 sm:py-12">
|
||||
{copy}
|
||||
</div>
|
||||
<div className="relative aspect-[4/5] w-full overflow-hidden bg-[#fdfcf8] sm:aspect-[3/4]">
|
||||
<Image
|
||||
src={image}
|
||||
alt={nameFa}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="origin-[22%_center] scale-[1.85] object-cover object-[22%_center]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: text over wide banner */}
|
||||
<div className="relative hidden w-full overflow-hidden aspect-[1920/585] md:block">
|
||||
<Image
|
||||
src={image}
|
||||
alt={nameFa}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
style={{ objectPosition: `left ${imageY}%` }}
|
||||
className="object-cover object-left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<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 className="absolute inset-y-0 right-3 z-10 flex items-center sm:right-10 lg:right-[max(2rem,calc((100%-1709px)/2+186px))]">
|
||||
{copy}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children ? (
|
||||
<div className="absolute inset-x-0 bottom-0 z-30">{children}</div>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Image from "next/image";
|
||||
import HoverFillButton from "./HoverFillButton";
|
||||
|
||||
type Highlight = {
|
||||
kicker: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
};
|
||||
|
||||
type Detail = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type OpeningFrameProps = {
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
eyebrow?: string;
|
||||
headline?: string;
|
||||
body?: string;
|
||||
highlights?: Highlight[];
|
||||
details?: Detail[];
|
||||
cta?: { label: string; href: string };
|
||||
};
|
||||
|
||||
const DEFAULT_HIGHLIGHTS: Highlight[] = [
|
||||
{ kicker: "تمرکز روی", value: "دوخت", caption: "و جزئیات پارچه" },
|
||||
{ kicker: "ساخته برای", value: "ماندگاری", caption: "در استفاده روزمره" },
|
||||
];
|
||||
|
||||
const DEFAULT_DETAILS: Detail[] = [
|
||||
{
|
||||
title: "کیفیت ساخت",
|
||||
body: "پارچه، دوخت و فیتینگ برای لباسی که هر روز پوشیده میشود.",
|
||||
},
|
||||
{
|
||||
title: "برش معاصر",
|
||||
body: "خطوطی که راحت مینشینند و شکل خود را حفظ میکنند.",
|
||||
},
|
||||
{
|
||||
title: "جزئیات ماندگار",
|
||||
body: "از یراق تا آستر، هر بخش با دقت انتخاب شده است.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function OpeningFrame({
|
||||
image = "/images/landscapes/frame1.jpg",
|
||||
imageAlt = "پرتره مد مانا کالکشن",
|
||||
eyebrow = "هنر پوشیدن",
|
||||
headline = "به سبک مانا",
|
||||
body = "هر قطعه مانا از نزدیک معنا پیدا میکند. پارچه، دوخت و برش طوری انتخاب شدهاند که هم برای پوشیدن روزمره راحت باشند و هم سالها شکل خود را حفظ کنند.",
|
||||
highlights = DEFAULT_HIGHLIGHTS,
|
||||
details = DEFAULT_DETAILS,
|
||||
cta = { label: "مشاهده کالکشن", href: "/products" },
|
||||
}: OpeningFrameProps) {
|
||||
return (
|
||||
<section className="opening-track relative h-[260vh] bg-black text-white">
|
||||
<div className="opening-stage sticky top-16 z-10 h-[calc(100dvh-4rem)] overflow-hidden bg-black sm:top-[4.5rem] sm:h-[calc(100dvh-4.5rem)]">
|
||||
<div className="opening-media absolute inset-0 origin-center scale-[1.38]">
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover object-[center_22%]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_28%,rgba(0,0,0,0.18)_68%,rgba(0,0,0,0.42)_100%)]" />
|
||||
<div className="opening-shade pointer-events-none absolute inset-0 bg-black/40 opacity-0" />
|
||||
|
||||
<div className="opening-headline absolute inset-0 flex items-center justify-center px-6 text-center">
|
||||
<div>
|
||||
<p className="font-doran text-[17px] font-medium leading-none text-white/80 sm:text-[21px] [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="mt-3 whitespace-nowrap font-doran text-[clamp(2.6rem,6.4vw,6rem)] font-bold leading-[1.04] tracking-[-0.02em] text-[#f5f5f7] [text-shadow:0_8px_36px_rgba(0,0,0,0.4)]">
|
||||
{headline}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="opening-copy pointer-events-none absolute inset-0 flex translate-y-7 items-center justify-center opacity-0">
|
||||
<div dir="rtl" className="w-full max-w-[1040px] px-5 sm:px-8">
|
||||
<p className="mx-auto max-w-[40rem] text-center font-doran text-[15px] font-normal leading-8 text-white sm:text-[17px] sm:leading-9 [text-shadow:0_2px_24px_rgba(0,0,0,0.5)]">
|
||||
{body}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-x-6 sm:mt-12 md:grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] md:gap-x-14">
|
||||
<div className="contents md:block">
|
||||
{highlights.map((item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className="border-t border-white/30 pt-3 md:mt-8 md:pt-4 md:first:mt-0"
|
||||
>
|
||||
<p className="text-[12px] font-light text-white/70 sm:text-[13px]">
|
||||
{item.kicker}
|
||||
</p>
|
||||
<p className="mt-1 font-doran text-[clamp(2.4rem,4.6vw,4.6rem)] font-medium leading-none text-white">
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] font-light text-white/70 sm:text-[13px]">
|
||||
{item.caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="col-span-2 mt-2 hidden md:col-span-1 md:mt-0 md:block">
|
||||
{details.map((item) => (
|
||||
<li key={item.title} className="border-t border-white/30 py-4">
|
||||
<p className="font-doran text-[17px] font-medium text-white">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-white/75">
|
||||
{item.body}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{cta?.label ? (
|
||||
<div className="pointer-events-auto mt-8 flex justify-center sm:mt-10">
|
||||
<HoverFillButton href={cta.href || "#"} variant="dark">
|
||||
{cta.label}
|
||||
</HoverFillButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { STORE_URL } from "@/lib/public-api";
|
||||
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="mx-auto flex max-w-[1920px] flex-col items-stretch justify-between gap-6 px-4 py-12 sm:px-6 sm:py-14 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">
|
||||
مایل هستید ما محصول شما را برای شما بفروشیم؟
|
||||
@@ -17,7 +17,7 @@ export default function PartnerCta() {
|
||||
<HoverFillButton
|
||||
href={`${STORE_URL}/account/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"
|
||||
className="!h-10 !w-fit max-w-none shrink-0 self-end !px-4 sm:!h-11 lg:self-center sm:!px-5 [&_span]:!text-sm sm:[&_span]:!text-base [&_img]:!h-4 [&_img]:!w-4"
|
||||
>
|
||||
شروع به همکاری
|
||||
</HoverFillButton>
|
||||
|
||||
@@ -239,15 +239,26 @@ export default function ProductRow({
|
||||
);
|
||||
}
|
||||
|
||||
const carouselTitle = OVERLAY_GRID_TITLES[promo];
|
||||
|
||||
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}`}
|
||||
className={`relative w-full pl-4 sm:pl-6 lg:pl-[120px] pr-4 sm:pr-12 lg:pr-[180px] ${className}`}
|
||||
>
|
||||
<div className="mb-4 md:hidden">
|
||||
<BilingualTitle
|
||||
titleFa={promoSlot?.nameFa?.trim() || carouselTitle.titleFa}
|
||||
titleEn={promoSlot?.nameEn?.trim() || carouselTitle.titleEn}
|
||||
align="center"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-stretch gap-2 sm:gap-2.5 lg:gap-3">
|
||||
<div
|
||||
className={`${CARD_W} seq-fade-item`}
|
||||
className={`${CARD_W} seq-fade-item hidden md:block`}
|
||||
style={
|
||||
stagger
|
||||
? {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SiteFooter({
|
||||
paymentBadges = "/images/home/banners/payment-badges.png",
|
||||
}: SiteFooterProps) {
|
||||
return (
|
||||
<footer dir="rtl" className="mt-8">
|
||||
<footer dir="rtl">
|
||||
<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">
|
||||
|
||||
@@ -9,6 +9,8 @@ import HeaderSearch from "./HeaderSearch";
|
||||
import HeaderUserLink from "./HeaderUserLink";
|
||||
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
|
||||
|
||||
const MOBILE_NAV_TOGGLE_ID = "mobile-nav-toggle";
|
||||
|
||||
type SiteHeaderProps = {
|
||||
megaTabs?: MegaTabData[];
|
||||
};
|
||||
@@ -37,6 +39,31 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [megaOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const toggle = document.getElementById(
|
||||
MOBILE_NAV_TOGGLE_ID,
|
||||
) as HTMLInputElement | null;
|
||||
if (!toggle) return;
|
||||
|
||||
const syncOverflow = () => {
|
||||
document.body.style.overflow = toggle.checked ? "hidden" : "";
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && toggle.checked) {
|
||||
toggle.checked = false;
|
||||
syncOverflow();
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener("change", syncOverflow);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
toggle.removeEventListener("change", syncOverflow);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openMega = () => {
|
||||
if (closeTimer.current) {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
@@ -50,18 +77,41 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
||||
closeTimer.current = window.setTimeout(() => setMegaOpen(false), 120);
|
||||
};
|
||||
|
||||
const closeMobile = () => {
|
||||
const toggle = document.getElementById(
|
||||
MOBILE_NAV_TOGGLE_ID,
|
||||
) as HTMLInputElement | null;
|
||||
if (!toggle?.checked) return;
|
||||
toggle.checked = false;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
};
|
||||
|
||||
const productTabs = megaTabs?.length ? megaTabs : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed inset-x-0 top-0 z-50">
|
||||
<div className="group/nav">
|
||||
<input
|
||||
id={MOBILE_NAV_TOGGLE_ID}
|
||||
type="checkbox"
|
||||
className="peer/mobile sr-only"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<header className="fixed inset-x-0 top-0 z-50 group-has-[:checked]/nav:z-[70]">
|
||||
<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]"
|
||||
: "h-14 py-1 opacity-100 sm:h-[5.25rem]"
|
||||
}`}
|
||||
aria-hidden={scrolled}
|
||||
>
|
||||
<ManaLogo href="/" variant="white" height={72} priority />
|
||||
<span className="sm:hidden">
|
||||
<ManaLogo href="/" variant="white" height={40} priority />
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
<ManaLogo href="/" variant="white" height={72} priority />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -75,6 +125,19 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
||||
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"
|
||||
>
|
||||
<label
|
||||
htmlFor={MOBILE_NAV_TOGGLE_ID}
|
||||
aria-label="منو"
|
||||
className="relative z-[70] flex h-9 w-9 cursor-pointer items-center justify-center text-ink transition-opacity hover:opacity-70 lg:hidden"
|
||||
>
|
||||
<span className="sr-only">منو</span>
|
||||
<span className="relative block h-3.5 w-[18px]">
|
||||
<span className="absolute inset-x-0 top-0 h-px bg-ink transition-transform duration-300 group-has-[:checked]/nav:translate-y-[7px] group-has-[:checked]/nav:rotate-45" />
|
||||
<span className="absolute inset-x-0 top-[7px] h-px bg-ink transition-opacity duration-300 group-has-[:checked]/nav:opacity-0" />
|
||||
<span className="absolute inset-x-0 bottom-0 h-px bg-ink transition-transform duration-300 group-has-[:checked]/nav:-translate-y-[7px] group-has-[:checked]/nav:-rotate-45" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<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;
|
||||
@@ -129,27 +192,13 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
||||
}`}
|
||||
aria-hidden={!scrolled}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<div className={scrolled ? "pointer-events-auto" : undefined}>
|
||||
<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>
|
||||
@@ -163,13 +212,78 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label
|
||||
htmlFor={MOBILE_NAV_TOGGLE_ID}
|
||||
aria-label="بستن منو"
|
||||
className="fixed inset-0 z-[60] hidden bg-black/25 backdrop-blur-md group-has-[:checked]/nav:block lg:hidden"
|
||||
/>
|
||||
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
dir="rtl"
|
||||
className="fixed inset-y-0 right-0 z-[65] hidden h-dvh w-[80%] flex-col overflow-hidden bg-white shadow-[-8px_0_32px_rgba(0,0,0,0.12)] group-has-[:checked]/nav:flex lg:hidden"
|
||||
>
|
||||
<div className="flex h-full flex-col gap-1 overflow-y-auto px-5 pb-8 pt-[max(4.5rem,calc(env(safe-area-inset-top)+3.5rem))]">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const isProducts = "hasDropdown" in link && link.hasDropdown;
|
||||
if (isProducts) {
|
||||
return (
|
||||
<details key={link.label} className="border-b border-ink/5 pb-1">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between py-3.5 text-right text-[15px] font-light text-ink [&::-webkit-details-marker]:hidden">
|
||||
{link.label}
|
||||
<Image
|
||||
src="/images/home/icons/caret-down.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
unoptimized
|
||||
/>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-1 pb-2 ps-3">
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="py-2 text-sm font-light text-ink/80 transition-opacity hover:opacity-70"
|
||||
>
|
||||
همه محصولات
|
||||
</Link>
|
||||
{productTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
onClick={closeMobile}
|
||||
className="py-2 text-sm font-light text-ink/80 transition-opacity hover:opacity-70"
|
||||
>
|
||||
{tab.nameFa}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="border-b border-ink/5 py-3.5 text-[15px] font-light text-ink transition-opacity hover:opacity-70"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 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]"
|
||||
scrolled ? "h-16 sm:h-[4.5rem]" : "h-[6.25rem] sm:h-[8.25rem]"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import HoverFillButton from "./HoverFillButton";
|
||||
|
||||
export type StoryStep = {
|
||||
en: string;
|
||||
fa: string;
|
||||
text: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
position?: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type StickyStoryProps = {
|
||||
steps: readonly StoryStep[];
|
||||
};
|
||||
|
||||
export default function StickyStory({ steps }: StickyStoryProps) {
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
const indexRef = useRef(0);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [under, setUnder] = useState(0);
|
||||
const [still, setStill] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || steps.length === 0) return;
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (reduce.matches) {
|
||||
setStill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stage = root.querySelector<HTMLElement>(".sticky-story__stage");
|
||||
if (!stage) return;
|
||||
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
const header =
|
||||
document.querySelector("header")?.getBoundingClientRect().height ?? 64;
|
||||
root.style.setProperty("--story-header-h", `${Math.round(header)}px`);
|
||||
|
||||
const total = root.offsetHeight - stage.offsetHeight;
|
||||
if (total <= 0) return;
|
||||
|
||||
const traveled = header - root.getBoundingClientRect().top;
|
||||
const progress = Math.min(1, Math.max(0, traveled / total));
|
||||
root.style.setProperty("--story", progress.toFixed(4));
|
||||
|
||||
const next = Math.min(
|
||||
steps.length - 1,
|
||||
Math.floor(progress * steps.length),
|
||||
);
|
||||
if (next !== indexRef.current) {
|
||||
setUnder(indexRef.current);
|
||||
indexRef.current = next;
|
||||
setIndex(next);
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!raf) raf = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
}, [steps.length]);
|
||||
|
||||
if (!steps.length) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={rootRef}
|
||||
className="sticky-story"
|
||||
aria-label="داستان کالکشن"
|
||||
style={{ ["--story" as string]: 0 }}
|
||||
>
|
||||
<div className="sticky-story__stage">
|
||||
{steps.map((step, i) => {
|
||||
const media = (
|
||||
<Image
|
||||
src={step.src}
|
||||
alt={step.alt}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
style={{ objectPosition: step.position || "center" }}
|
||||
priority={i === 0}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`${step.en}-${step.src}`}
|
||||
className={[
|
||||
"sticky-story__slide",
|
||||
i === index ? "is-on" : "",
|
||||
i === under && i !== index ? "is-under" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-hidden={!still && i !== index}
|
||||
>
|
||||
<div className="sticky-story__media">{media}</div>
|
||||
<div className="sticky-story__shade" />
|
||||
<div className="sticky-story__copy">
|
||||
<p className="sticky-story__kicker" dir="ltr">
|
||||
{step.en}
|
||||
</p>
|
||||
<h2 className="font-doran">{step.fa}</h2>
|
||||
<p className="sticky-story__lead">{step.text}</p>
|
||||
{step.href ? (
|
||||
<div className="sticky-story__cta">
|
||||
<HoverFillButton href={step.href} variant="dark">
|
||||
مشاهده کالکشن
|
||||
</HoverFillButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="sticky-story__plate" aria-hidden="true" />
|
||||
|
||||
<ol className="sticky-story__rail" aria-hidden="true">
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={`${step.en}-rail`}
|
||||
className={i === index ? "is-on" : undefined}
|
||||
>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user