Improve mobile storefront layout, navigation, and product browsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-11 15:02:41 +03:30
co-authored by Cursor
parent b3df0d3eb4
commit 4e4dd731b2
38 changed files with 1571 additions and 332 deletions
+2 -2
View File
@@ -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>
+229 -64
View File
@@ -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>
+1 -1
View File
@@ -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>
+5 -5
View File
@@ -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"
/>
</>
);
+53 -105
View File
@@ -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>
);
}
+136
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
+13 -2
View File
@@ -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
? {
+1 -1
View File
@@ -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">
+135 -21
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}