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