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
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+1 -1
View File
@@ -32,7 +32,7 @@ export default async function AboutPage() {
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
export const metadata: Metadata = {
title: "سبد خرید | مانا کالکشن",
description: "سبد خرید مانا کالکشن",
};
export default async function CartPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa="سبد خرید"
titleEn="Cart"
heroImage={home.heroSlides[0]?.image || "/images/home/hero/1.jpg"}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "سبد خرید" },
]}
/>
</div>
<section className="mx-auto max-w-[1200px] px-4 py-16 text-center sm:px-6 sm:py-20 lg:px-8">
<p className="font-doran text-xl font-medium text-ink sm:text-2xl">
سبد خرید شما خالی است
</p>
<p className="mx-auto mt-3 max-w-md text-sm font-light leading-7 text-muted sm:text-base">
محصولی به سبد اضافه نشده است. از کالکشن‌ها دیدن کنید و انتخاب خود را
شروع کنید.
</p>
<Link
href="/products"
className="mt-8 inline-flex h-11 items-center justify-center rounded-[4px] border border-ink bg-ink px-8 text-sm font-light text-white transition-opacity hover:opacity-85"
>
مشاهده محصولات
</Link>
</section>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+1 -1
View File
@@ -174,7 +174,7 @@ export default async function CategoryPage({ params, searchParams }: PageProps)
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function CategoriesIndexPage() {
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
+1 -1
View File
@@ -30,7 +30,7 @@ export default async function ContactPage() {
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
+390 -1
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--background: #f5f5f5;
--foreground: #0a0a0a;
--surface: #ffffff;
--muted: #6b6b6b;
@@ -235,6 +235,395 @@ body {
}
}
/* Sticky close-up: scroll pulls back, then copy + button appear over the image. */
.opening-track {
view-timeline-name: --opening;
view-timeline-axis: block;
}
.opening-media,
.opening-shade,
.opening-headline,
.opening-copy {
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --opening;
animation-duration: auto;
}
.opening-media {
animation-name: opening-media;
animation-range: cover 0% cover 68%;
}
.opening-headline {
animation-name: opening-headline;
animation-range: cover 42% cover 56%;
}
.opening-shade {
animation-name: opening-shade;
animation-range: cover 46% cover 64%;
}
.opening-copy {
animation-name: opening-copy, opening-copy-hit;
animation-timing-function: linear, step-end;
animation-range: cover 52% cover 70%, cover 52% cover 70%;
}
@keyframes opening-media {
from {
transform: scale(1.38);
}
to {
transform: scale(1.06);
}
}
@keyframes opening-headline {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes opening-shade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes opening-copy {
from {
opacity: 0;
transform: translate3d(0, 28px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes opening-copy-hit {
0%,
65% {
pointer-events: none;
}
100% {
pointer-events: auto;
}
}
@supports not (animation-timeline: view()) {
.opening-track {
height: auto;
}
.opening-stage {
position: relative;
top: auto;
height: calc(100dvh - 4.5rem);
}
.opening-media,
.opening-shade,
.opening-headline,
.opening-copy {
animation: none;
}
.opening-media {
transform: scale(1.06);
}
.opening-shade {
opacity: 0.55;
}
.opening-headline {
opacity: 0;
}
.opening-copy {
opacity: 1;
transform: none;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.opening-track {
height: auto;
view-timeline-name: none;
}
.opening-stage {
position: relative;
top: auto;
height: min(86dvh, 920px);
}
.opening-media,
.opening-shade,
.opening-headline,
.opening-copy {
animation: none;
}
.opening-media {
transform: scale(1.06);
}
.opening-shade {
opacity: 0.55;
}
.opening-headline {
opacity: 0;
}
.opening-copy {
opacity: 1;
transform: none;
pointer-events: auto;
}
}
/* Four-image sticky story (Ayoubi-style): scroll advances full-bleed slides. */
.sticky-story {
--story: 0;
--story-header-h: 4rem;
--frame-inset: 1.6rem;
--story-accent: #e8e2d8;
height: calc((100dvh - var(--story-header-h)) * 4);
background: #000;
}
@media (min-width: 640px) {
.sticky-story {
--story-header-h: 4.5rem;
}
}
.sticky-story__stage {
position: sticky;
top: var(--story-header-h);
height: calc(100dvh - var(--story-header-h));
overflow: hidden;
background: #000;
}
.sticky-story__slide {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
}
.sticky-story__slide.is-under {
opacity: 1;
z-index: 0;
}
.sticky-story__slide.is-under .sticky-story__copy {
opacity: 0;
}
.sticky-story__slide.is-on {
z-index: 1;
opacity: 1;
animation: sticky-story-fade 0.8s ease;
}
.sticky-story__media,
.sticky-story__shade,
.sticky-story__copy {
position: absolute;
inset: 0;
}
.sticky-story__media {
display: block;
}
.sticky-story__shade {
background:
linear-gradient(
to left,
rgba(7, 7, 8, 0.82) 0%,
rgba(7, 7, 8, 0.48) 38%,
rgba(7, 7, 8, 0.18) 68%
),
linear-gradient(to top, rgba(7, 7, 8, 0.45) 0%, transparent 42%),
linear-gradient(to right, rgba(7, 7, 8, 0.35), transparent 30%);
pointer-events: none;
}
.sticky-story__plate {
position: absolute;
z-index: 2;
inset: var(--frame-inset);
border: 1px solid var(--story-accent);
pointer-events: none;
}
.sticky-story__copy {
z-index: 3;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.85rem;
max-width: 38rem;
padding: 2rem clamp(2rem, 7vw, 6rem);
text-align: start;
color: #f3eee6;
pointer-events: none;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45);
}
.sticky-story__slide.is-on .sticky-story__copy {
pointer-events: auto;
}
.sticky-story__cta {
margin-top: 0.6rem;
}
.sticky-story__kicker {
margin: 0;
font-family: var(--font-montserrat), system-ui, sans-serif;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(243, 238, 230, 0.72);
}
.sticky-story__copy h2 {
margin: 0;
font-size: clamp(2.1rem, 5vw, 3.5rem);
font-weight: 500;
line-height: 1.3;
color: #f5f5f7;
}
.sticky-story__lead {
margin: 0;
max-width: 24rem;
font-size: 0.95rem;
font-weight: 300;
line-height: 1.8;
color: rgba(243, 238, 230, 0.86);
}
.sticky-story__rail {
position: absolute;
z-index: 2;
top: 50%;
inset-inline-end: calc(var(--frame-inset) + 1.15rem);
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
pointer-events: none;
}
.sticky-story__rail li {
font-family: var(--font-montserrat), system-ui, sans-serif;
font-size: 0.72rem;
letter-spacing: 0.16em;
color: rgba(243, 238, 230, 0.45);
}
.sticky-story__rail li.is-on {
color: var(--story-accent);
}
@keyframes sticky-story-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 767px) {
.sticky-story {
--frame-inset: 0.75rem;
}
.sticky-story__shade {
background: linear-gradient(
to top,
rgba(7, 7, 8, 0.9) 0%,
rgba(7, 7, 8, 0.55) 34%,
rgba(7, 7, 8, 0.08) 62%
);
}
.sticky-story__copy {
justify-content: flex-end;
max-width: none;
padding: calc(var(--frame-inset) + 1.35rem)
calc(var(--frame-inset) + 1.25rem)
calc(var(--frame-inset) + 2.1rem);
}
.sticky-story__copy h2 {
font-size: 1.9rem;
}
.sticky-story__rail {
top: calc(var(--frame-inset) + 1.1rem);
bottom: auto;
inset-inline-end: calc(var(--frame-inset) + 1.35rem);
transform: none;
flex-direction: row;
gap: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sticky-story {
height: auto;
}
.sticky-story__stage {
position: relative;
top: auto;
height: auto;
overflow: visible;
}
.sticky-story__slide,
.sticky-story__slide.is-on,
.sticky-story__slide.is-under {
position: relative;
min-height: min(78dvh, 720px);
opacity: 1;
animation: none;
}
.sticky-story__slide.is-under .sticky-story__copy {
opacity: 1;
}
.sticky-story__rail {
display: none;
}
}
.mana-canvas {
background:
radial-gradient(ellipse 80% 60% at 20% 20%, rgba(0, 0, 0, 0.02), transparent 55%),
+13 -14
View File
@@ -1,5 +1,6 @@
import SiteHeader from "@/components/home/SiteHeader";
import HeroSlider from "@/components/home/HeroSlider";
import OpeningFrame from "@/components/home/OpeningFrame";
// import CategoryStrip from "@/components/home/CategoryStrip";
import CategoryBento from "@/components/home/CategoryBento";
import ProductRow from "@/components/home/ProductRow";
@@ -8,18 +9,15 @@ import PartnerCta from "@/components/home/PartnerCta";
import FeaturesBar from "@/components/home/FeaturesBar";
import BlackCollection from "@/components/home/BlackCollection";
import AboutSection from "@/components/home/AboutSection";
import StickyStory from "@/components/home/StickyStory";
import SiteFooter from "@/components/home/SiteFooter";
import SubscribeModal from "@/components/home/SubscribeModal";
import { mapHomeSlots } from "@/lib/map-home-slots";
import {
fetchPublicWebsiteSlots,
itemsForSlot,
} from "@/lib/website-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import { getStorefrontCatalog } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
resolveHomeCategoryStrip,
} from "@/lib/categories-api";
export default async function HomePage() {
@@ -43,22 +41,16 @@ export default async function HomePage() {
})),
);
const categories = resolveHomeCategoryStrip(
apiCategories,
home.categories,
itemsForSlot(slotItems, "category-strip").length > 0,
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<SubscribeModal />
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<HeroSlider slides={home.heroSlides} />
</div>
{/* <CategoryStrip categories={categories} /> */}
<CategoryBento categories={categories} />
{/* Curated bento art from website slots (with local transparent defaults). */}
<CategoryBento categories={home.categories} />
<div className="flex flex-col gap-10 lg:gap-14">
<ProductRow
@@ -84,6 +76,12 @@ export default async function HomePage() {
stagger
promoSlot={home.promoDiscount}
/>
<OpeningFrame
image={home.openingFrame.image}
imageAlt={home.openingFrame.imageAlt}
/>
<FeaturesBar />
<BlackCollection items={home.blackItems} />
<ProductRow
@@ -103,6 +101,7 @@ export default async function HomePage() {
image={home.about.image}
imageSecondary={home.about.imageSecondary}
/>
<StickyStory steps={home.stickyStory} />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
+1 -1
View File
@@ -89,7 +89,7 @@ export default async function ProductDetailPage({ params }: PageProps) {
.slice(0, 8);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductDetailHero
+1 -1
View File
@@ -124,7 +124,7 @@ export default async function ProductsPage({ searchParams }: PageProps) {
: LIST_PAGE_META.breadcrumbs;
return (
<main className="home-page min-h-dvh bg-white text-ink">
<main className="home-page min-h-dvh bg-background text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
+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>
);
}
+229 -93
View File
@@ -2,7 +2,8 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useId, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import {
buildProductListHref,
formatToman,
@@ -143,7 +144,10 @@ export default function ProductFilters({
categoryLabel = null,
}: ProductFiltersProps) {
const router = useRouter();
const titleId = useId();
const [isPending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [name, setName] = useState(initial?.q ?? "");
const [minPrice, setMinPrice] = useState(initial?.min ?? "");
@@ -153,6 +157,10 @@ export default function ProductFilters({
);
const [sale, setSale] = useState(Boolean(initial?.sale));
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
setName(initial?.q ?? "");
setMinPrice(initial?.min ?? "");
@@ -168,6 +176,20 @@ export default function ProductFilters({
lockedCategory?.id,
]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open]);
const navigate = (next: {
q?: string;
cat?: string;
@@ -200,6 +222,7 @@ export default function ProductFilters({
max: maxPrice,
sale,
});
setOpen(false);
};
const clearAll = () => {
@@ -211,6 +234,7 @@ export default function ProductFilters({
startTransition(() => {
router.push(lockedCategory ? "/products" : basePath);
});
setOpen(false);
};
const removeChip = (id: ActiveFilter["id"]) => {
@@ -239,110 +263,222 @@ export default function ProductFilters({
categoryLabel,
});
const fields = (
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-start sm:gap-3">
<FilterField
label="نام محصول"
value={name}
onChange={setName}
onSubmit={apply}
placeholder="نام محصول"
iconSrc="/images/home/icons/search.svg"
className="sm:max-w-[269px]"
/>
<FilterField
label="حداقل قیمت"
value={minPrice}
onChange={setMinPrice}
onSubmit={apply}
placeholder="حداقل قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
<FilterField
label="حداکثر قیمت"
value={maxPrice}
onChange={setMaxPrice}
onSubmit={apply}
placeholder="حداکثر قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
</div>
);
const actions = (
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="بازنشانی فیلترها"
onClick={clearAll}
className="flex size-11 items-center justify-center transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/refresh.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
<button
type="button"
aria-label="اعمال فیلتر"
onClick={apply}
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
>
<Image
src="/images/home/icons/funnel.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
);
const chips =
active.length > 0 ? (
<ul className="mt-3 flex flex-wrap items-center justify-start gap-3">
{active.map((chip) => (
<li
key={chip.id}
className="inline-flex h-10 items-center gap-2 rounded-[5px] bg-[#eee] px-3"
>
<button
type="button"
aria-label={`حذف فیلتر ${chip.label}`}
onClick={() => removeChip(chip.id)}
className="shrink-0 transition-opacity hover:opacity-60"
>
<Image
src="/images/home/icons/close.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
<p className="text-sm font-light uppercase">
<span className="text-[#808080]">{chip.label}</span>
<span className="text-[#b4b4b4]">: </span>
<span className="text-ink">{chip.value}</span>
</p>
</li>
))}
</ul>
) : null;
const modal =
open && mounted
? createPortal(
<div
className="fixed inset-0 z-[200] flex items-end justify-center p-0 sm:items-center sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<button
type="button"
className="absolute inset-0 bg-ink/30 backdrop-blur-md"
aria-label="بستن فیلترها"
onClick={() => setOpen(false)}
/>
<div
dir="rtl"
className="relative z-10 flex max-h-[min(92dvh,640px)] w-full max-w-[480px] flex-col overflow-hidden rounded-t-[12px] border border-[#e5e5e5] bg-white shadow-[0_16px_48px_rgba(0,0,0,0.16)] sm:rounded-[12px]"
>
<div className="flex shrink-0 items-center justify-between border-b border-[#eee] px-4 py-4">
<h2
id={titleId}
className="text-base font-normal text-ink sm:text-[18px]"
>
فیلترها
</h2>
<button
type="button"
aria-label="بستن"
onClick={() => setOpen(false)}
className="flex size-10 items-center justify-center transition-opacity hover:opacity-60"
>
<Image
src="/images/home/icons/close.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
{fields}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[#eee] px-4 py-3">
<button
type="button"
onClick={clearAll}
className="h-11 px-3 text-sm font-light text-ink/70 transition-opacity hover:opacity-70"
>
پاک کردن
</button>
<button
type="button"
onClick={apply}
className="inline-flex h-11 min-w-[8.5rem] items-center justify-center gap-2 rounded-[5px] bg-ink px-5 text-sm font-light text-white transition-opacity hover:opacity-85"
>
<Image
src="/images/home/icons/funnel.svg"
alt=""
width={18}
height={18}
unoptimized
/>
اعمال فیلتر
</button>
</div>
</div>
</div>,
document.body,
)
: null;
return (
<div
dir="rtl"
className={`mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px] ${isPending ? "opacity-80" : ""}`}
>
<div className="flex flex-col gap-3 rounded-[5px] border border-[#e5e5e5] px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<p className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
فیلترها
</p>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-start sm:gap-3">
<FilterField
label="نام محصول"
value={name}
onChange={setName}
onSubmit={apply}
placeholder="نام محصول"
iconSrc="/images/home/icons/search.svg"
className="sm:max-w-[269px]"
/>
<FilterField
label="حداقل قیمت"
value={minPrice}
onChange={setMinPrice}
onSubmit={apply}
placeholder="حداقل قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
<FilterField
label="حداکثر قیمت"
value={maxPrice}
onChange={setMaxPrice}
onSubmit={apply}
placeholder="حداکثر قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="بازنشانی فیلترها"
onClick={clearAll}
className="flex size-11 items-center justify-center transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/refresh.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
<button
type="button"
aria-label="اعمال فیلتر"
onClick={apply}
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
>
{/* Mobile: filter button opens modal */}
<div className="sm:hidden">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className="flex h-11 w-full items-center justify-between rounded-[5px] border border-[#e5e5e5] bg-white px-4 text-ink transition-opacity hover:opacity-80"
>
<span className="text-base font-normal">فیلترها</span>
<span className="flex size-9 items-center justify-center rounded-[5px] bg-ink">
<Image
src="/images/home/icons/funnel.svg"
alt=""
width={22}
height={22}
width={18}
height={18}
unoptimized
/>
</button>
</div>
</span>
</button>
{chips}
</div>
{active.length > 0 ? (
<ul className="mt-3 flex flex-wrap items-center justify-start gap-3">
{active.map((chip) => (
<li
key={chip.id}
className="inline-flex h-10 items-center gap-2 rounded-[5px] bg-[#eee] px-3"
>
<button
type="button"
aria-label={`حذف فیلتر ${chip.label}`}
onClick={() => removeChip(chip.id)}
className="shrink-0 transition-opacity hover:opacity-60"
>
<Image
src="/images/home/icons/close.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
<p className="text-sm font-light uppercase">
<span className="text-[#808080]">{chip.label}</span>
<span className="text-[#b4b4b4]">: </span>
<span className="text-ink">{chip.value}</span>
</p>
</li>
))}
</ul>
) : null}
{/* Desktop / tablet: inline filters */}
<div className="hidden sm:block">
<div className="flex flex-col gap-3 rounded-[5px] border border-[#e5e5e5] px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<p className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
فیلترها
</p>
{fields}
{actions}
</div>
{chips}
</div>
{modal}
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
return (
<ul
dir="rtl"
className="mx-auto grid w-full max-w-[1920px] grid-cols-2 gap-3 px-4 sm:grid-cols-3 sm:gap-4 sm:px-6 md:grid-cols-4 lg:grid-cols-5 lg:gap-[22px] lg:px-[120px]"
className="mx-auto grid w-full max-w-[1920px] grid-cols-1 gap-4 px-4 sm:grid-cols-3 sm:gap-4 sm:px-6 md:grid-cols-4 lg:grid-cols-5 lg:gap-[22px] lg:px-[120px]"
>
{products.map((product) => (
<li key={product.id} className="min-w-0">
+39
View File
@@ -431,3 +431,42 @@ export const SOCIAL_LINKS = [
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
{ id: "linkedin", href: "#", icon: "/images/home/social/linkedin.svg", label: "لینکدین" },
] as const;
export const STORY_STEPS = [
{
en: "Mantel",
fa: "مانتو",
text: "مانتوهایی با برش معاصر؛ ساده، شهری و مناسب هر روز.",
src: "/images/landscapes/ian-dooley-C6BrEIucDDU-unsplash.jpg",
alt: "کالکشن مانتو مانا",
position: "center 28%",
href: "/products?category=coat",
},
{
en: "Shirts",
fa: "پیراهن",
text: "پیراهن‌هایی با پارچه لطیف و ایستایی تمیز برای استایل روزمره.",
src: "/images/landscapes/tamara-bellis-nbC4EyCxhb8-unsplash.jpg",
alt: "کالکشن پیراهن مانا",
position: "center 35%",
href: "/products?category=shirts",
},
{
en: "Pants",
fa: "شلوار",
text: "شلوارهایی با فرم خوش‌ایست که شکل خود را حفظ می‌کنند.",
src: "/images/landscapes/vinicius-amnx-amano-k2yixQS_YH0-unsplash.jpg",
alt: "کالکشن شلوار مانا",
position: "center 30%",
href: "/products?category=pants",
},
{
en: "Bags",
fa: "کیف زنانه",
text: "کیف‌هایی با جزئیات دقیق؛ تکمیل‌کننده استایل مانا.",
src: "/images/landscapes/mojtaba-mosayebzadeh-1nLTyduE1cY-unsplash.jpg",
alt: "کالکشن کیف زنانه مانا",
position: "center 40%",
href: "/products?category=bags",
},
] as const;
+100 -14
View File
@@ -2,17 +2,69 @@ import type { Category } from "@/data/home";
import {
ABOUT,
BLACK_ITEMS,
CATEGORIES,
MEGA_MENU_TABS,
STORY_STEPS,
} from "@/data/home";
import type { HeroSlideData } from "@/components/home/HeroSlider";
import type { StoryStep } from "@/components/home/StickyStory";
import { categoryPath } from "@/lib/category-path";
import type { PublicWebsiteSlotItem } from "@/lib/website-slots";
import { firstForSlot, itemsForSlot } from "@/lib/website-slots";
/** Slot order for CategoryBento / category-strip (sortOrder 0…5). */
const CATEGORY_BENTO_FALLBACKS: Category[] = [
{
id: "coat",
nameFa: "مانتو",
nameEn: "Mantel",
image: "/images/transparent/mantel.png",
href: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" }),
},
{
id: "shirts",
nameFa: "پیراهن",
nameEn: "Shirts",
image: "/images/transparent/shirt.png",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
id: "pants",
nameFa: "شلوار",
nameEn: "Pants",
image: "/images/transparent/pants.png",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
{
id: "bags",
nameFa: "کیف زنانه",
nameEn: "Wemon Bag",
image: "/images/transparent/bag.png",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
{
id: "scarf",
nameFa: "شال",
nameEn: "Scarf",
image: "/images/transparent/scarf.png",
href: categoryPath({ id: "scarf", nameEn: "Scarf", nameFa: "شال" }),
},
{
id: "shoes",
nameFa: "کفش",
nameEn: "Shoe",
image: "/images/transparent/shoe.png",
href: categoryPath({ id: "shoes", nameEn: "Shoe", nameFa: "کفش" }),
},
];
export type HomeSlotContent = {
heroSlides: HeroSlideData[];
categories: Category[];
openingFrame: {
image: string;
imageAlt: string;
};
stickyStory: StoryStep[];
leather: {
image: string;
nameFa: string;
@@ -96,6 +148,12 @@ function firstSlot(
return slotItems(items, slug, ...legacy)[0] ?? null;
}
/** Only accept curated transparent PNGs from slots — ignore legacy white-bg icons. */
function slotTransparentImage(imageUrl?: string | null) {
if (!imageUrl) return null;
return imageUrl.includes("/images/transparent/") ? imageUrl : null;
}
export function mapHomeSlots(
items: PublicWebsiteSlotItem[],
): HomeSlotContent {
@@ -118,32 +176,58 @@ export function mapHomeSlots(
const categoryItems = slotItems(items, "category-strip");
const categories: Category[] =
categoryItems.length > 0
? categoryItems.map((item, index) => {
const fallback = CATEGORIES[index % CATEGORIES.length];
? CATEGORY_BENTO_FALLBACKS.map((fallback, index) => {
const item =
categoryItems.find((row) => row.sortOrder === index) ??
categoryItems[index];
return {
id: item.id || `cat-${index}`,
nameFa: item.nameFa || `دسته ${index + 1}`,
nameEn: item.nameEn || "",
image: item.imageUrl || fallback.image,
href:
item.link ||
(fallback
? categoryPath(fallback)
: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" })),
id: fallback.id,
// Keep curated bento labels; old category-strip rows had unrelated names.
nameFa: fallback.nameFa,
nameEn: fallback.nameEn,
// Prefer curated transparent art. Only use a slot image when it is
// already a storefront transparent asset (not legacy white-bg icons).
image: slotTransparentImage(item?.imageUrl) || fallback.image,
href: item?.link || fallback.href,
};
})
: CATEGORIES.map((cat) => {
: CATEGORY_BENTO_FALLBACKS.map((cat) => {
const legacy = firstForSlot(items, `home.category.${cat.id}`);
if (!legacy) return cat;
return {
...cat,
nameFa: legacy.nameFa || cat.nameFa,
nameEn: legacy.nameEn || cat.nameEn,
image: legacy.imageUrl || cat.image,
image: slotTransparentImage(legacy.imageUrl) || cat.image,
href: legacy.link || cat.href || categoryPath(cat),
};
});
const openingSlot = firstSlot(items, "opening-frame");
const openingFrame = {
image: openingSlot?.imageUrl || "/images/landscapes/frame1.jpg",
imageAlt: openingSlot?.nameFa || "پرتره مد مانا کالکشن",
};
const storyItems = slotItems(items, "sticky-story");
/** Prefer curated category titles; only take media/link from slots. */
const stickyStory: StoryStep[] = STORY_STEPS.map((step, index) => {
const item =
storyItems.find((row) => row.sortOrder === index) ?? storyItems[index];
const slotTitleIsCurated =
item?.nameFa === step.fa || item?.nameEn === step.en;
return {
en: slotTitleIsCurated && item?.nameEn ? item.nameEn : step.en,
fa: slotTitleIsCurated && item?.nameFa ? item.nameFa : step.fa,
text:
slotTitleIsCurated && item?.subtext ? item.subtext : step.text,
src: item?.imageUrl || step.src,
alt: step.alt,
position: step.position,
href: item?.link || step.href,
};
});
const leatherSlot = firstSlot(
items,
"leather-banner",
@@ -284,6 +368,8 @@ export function mapHomeSlots(
return {
heroSlides,
categories,
openingFrame,
stickyStory,
leather,
promoSale,
promoDiscount,