Wire homepage banners to Meshkee static-image slots.

Publish a slot catalog so the dashboard can import keys, and keep local placeholders when a slot is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-25 17:54:10 +03:30
co-authored by Cursor
parent 46e4835360
commit 49b83223f1
12 changed files with 766 additions and 147 deletions
+17
View File
@@ -0,0 +1,17 @@
---
description: Keep Meshkee static-image slot catalog in sync with on-page image sizes.
globs: lib/static-images.ts,app/meshkee/**,components/HeroSlider.tsx,components/Banners.tsx,components/Categories.tsx,components/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
alwaysApply: false
---
# Static image slot catalog
Named homepage slots are defined in `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`) and published at `GET /meshkee/static-image-slots`.
When you change a slot’s layout, CSS aspect ratio, or displayed width:
1. Update that slot’s `aspectRatio` (`W:H`) and `recommendedWidth` in `STATIC_IMAGE_SLOT_CATALOG` in the **same change**.
2. Keep using `slot.aspectRatio` from the Meshkee API on the rendered frame.
3. Do not invent upload or CMS APIs. Empty/failed slots keep the local placeholder.
Keys: `slider`, `banners-below-slider`, `categories`, `four-banner-section`, `three-banner-row`, `about-us-image`.
+44
View File
@@ -0,0 +1,44 @@
# Salar Store — AI context
Persian RTL storefront for **salarstore.com**. Next.js App Router. Do not invent Meshkee admin/upload APIs.
## Meshkee
- Apex domain: `salarstore.com`
- API: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_API_BASE_URL`)
- Tenant: `GET /tenants/salarstore.com` → `businessId`
- Public content: `/tenants/salarstore.com/...` (no auth)
- Customer app: `https://customer.salarstore.com`
- Business app: `https://business.salarstore.com`
Docs: https://api.meshkee.com/docs/website
## Static image slots
Dashboard-replaceable homepage images. Fetch `GET /tenants/salarstore.com/website/static-images?pageKey=home`. Empty/fail → keep local placeholders.
The business dashboard **Refresh** button imports keys from:
`GET https://salarstore.com/meshkee/static-image-slots`
Source of truth: `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`).
| key | kind | itemCount | aspectRatio | where |
| --- | --- | --- | --- | --- |
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero |
| `banners-below-slider` | list | 2 | `13:5` | Two tiles under the slider |
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row |
| `four-banner-section` | list | 4 | `21:9` | 2×2 banner grid |
| `three-banner-row` | list | 3 | `12:5` | Three-up row |
| `about-us-image` | single | 1 | `8:5` | Home about block + `/aboutus` hero |
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`). `url` → `<img src>`. `linkUrl` → wrap in `<a>`.
- Apply `slot.aspectRatio` from the API on the rendered frame.
- **If a slot’s on-page size or aspect ratio changes, update `STATIC_IMAGE_SLOT_CATALOG` (`aspectRatio` + `recommendedWidth`) in the same change** so the dashboard cropper stays in sync.
- Do not add upload/CMS endpoints on this website.
## Cart + customer session
Guest cart key `meshkee-guest-cart` (localStorage + cookie on `.salarstore.com`). Line `id` is the **store item variant id**. Continue hands off to `https://customer.salarstore.com/checkout/cart#guestCart=...` (guests go via login with that hash inside `redirect`). Shared cookies: `meshkee_customer_access_token` / `meshkee_customer_refresh_token`.
+20
View File
@@ -0,0 +1,20 @@
Wire this page to salarstore.com static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- banners-below-slider > two image
- categories > duplicatable
- four-banner-section → four slot
- three-banner-row → three slot
- about us image - one slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+23 -9
View File
@@ -1,27 +1,41 @@
import type { Metadata } from "next";
import Image from "next/image";
import { PageHero } from "@/components/PageHero";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import {
STATIC_IMAGE_KEYS,
firstImageForSlot,
getStaticImageSlot,
slotAlt,
} from "@/lib/static-images";
export const metadata: Metadata = {
title: `درباره ما | ${SITE.nameFa}`,
description: SITE.about,
};
export default function AboutPage() {
export default async function AboutPage() {
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
const image = slot ? firstImageForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : undefined;
return (
<>
<PageHero title="درباره ما" english="About Us" />
<section className="container-site py-10 md:py-14">
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
<Image
src="/images/pages/about-bg.webp"
alt=""
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
{image ? (
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
) : (
<Image
src="/images/pages/about-bg.webp"
alt={SITE.nameFa}
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
)}
<div
className="pointer-events-none absolute inset-0"
style={{
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/static-images";
export function GET() {
return NextResponse.json(
{ slots: STATIC_IMAGE_SLOT_CATALOG },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=60",
},
},
);
}
+34 -6
View File
@@ -6,18 +6,46 @@ import { Categories } from "@/components/Categories";
import { HeroSlider } from "@/components/HeroSlider";
import { SpecialSale } from "@/components/SpecialSale";
import { TopSelling } from "@/components/TopSelling";
import {
STATIC_IMAGE_KEYS,
firstImageForSlot,
getStaticImageSlots,
imagesForSlot,
slotByKey,
} from "@/lib/static-images";
export default async function Home() {
const slots = await getStaticImageSlots("home");
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.bannersBelowSlider);
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBannerSection);
const threeSlot = slotByKey(slots, STATIC_IMAGE_KEYS.threeBannerRow);
const aboutSlot = slotByKey(slots, STATIC_IMAGE_KEYS.aboutUsImage);
export default function Home() {
return (
<>
<HeroSlider />
<HeroSlider
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
aspectRatio={sliderSlot?.aspectRatio}
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.bannersBelowSlider)}
sideAspectRatio={sideSlot?.aspectRatio}
/>
<SpecialSale />
<Categories />
<BannerGrid4 />
<Categories images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
<BannerGrid4
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBannerSection)}
aspectRatio={fourSlot?.aspectRatio}
/>
<TopSelling />
<BannerRow3 />
<BannerRow3
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.threeBannerRow)}
aspectRatio={threeSlot?.aspectRatio}
/>
<Brands />
<AboutUs />
<AboutUs
image={firstImageForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)}
aspectRatio={aboutSlot?.aspectRatio}
/>
<BlogCarousel />
</>
);
+25 -10
View File
@@ -1,8 +1,17 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
export function AboutUs() {
export function AboutUs({
image,
aspectRatio,
}: {
image?: StaticImageWithUrl;
aspectRatio?: string;
}) {
return (
<section id="about" className="container-site mt-14">
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
@@ -14,15 +23,21 @@ export function AboutUs() {
مشاهده بیشتر
</Link>
</div>
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
{image ? (
<div className="relative overflow-hidden rounded-2xl bg-white" style={slotAspectStyle(aspectRatio, "8:5")}>
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
</div>
) : (
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
)}
</div>
</section>
);
+92 -28
View File
@@ -1,47 +1,111 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { banners3, banners4 } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Media } from "./Media";
export function BannerGrid4() {
const banner4Overlay = {
background: "linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
};
type BannerSlotProps = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-2 gap-3 md:gap-4">
{banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
}}
/>
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
{images.length > 0
? images.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(aspectRatio, "21:9")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
{hasCopy ? (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
<div className="pointer-events-none absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt={banner.title} fill sizes="50vw" className="object-cover object-center" />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
}
export function BannerRow3() {
export function BannerRow3({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt="" className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
{images.length > 0
? images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image)}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))
: banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
</section>
);
+67 -23
View File
@@ -1,9 +1,12 @@
import Link from "next/link";
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { categories } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { DrawFrame } from "./DrawFrame";
export function Categories() {
export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
return (
<section className="container-site mt-12">
<div className="flex items-end justify-between mb-6">
@@ -13,28 +16,69 @@ export function Categories() {
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{categories.map((cat) => (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span dir="ltr" className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase">
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
))}
{images.length > 0
? images.map((image) => {
const label = slotAlt(image);
const card = (
<div className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand">
<div className="size-[72px] sm:size-20 shrink-0 overflow-hidden" style={slotAspectStyle("1:1")}>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={label}
imgClassName="h-full w-full object-contain"
/>
</div>
{label || image.subtext ? (
<span className="min-w-0">
{label ? (
<span className="block text-lg font-medium text-ink leading-none">{label}</span>
) : null}
{image.subtext ? (
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{image.subtext}
</span>
) : null}
</span>
) : null}
<DrawFrame />
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl}>
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: categories.map((cat) => (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
))}
</div>
</section>
);
+210 -71
View File
@@ -3,103 +3,242 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { SlotImage } from "@/components/SlotImage";
import { heroSides, heroSlides } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
const overlayStyle = {
background:
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
};
export function HeroSlider() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
sideImages?: StaticImageWithUrl[];
sideAspectRatio?: string;
};
export function HeroSlider({
images = [],
aspectRatio,
sideImages = [],
sideAspectRatio,
}: Props) {
const slides = images;
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const count = slides.length > 0 ? slides.length : heroSlides.length;
useEffect(() => {
if (paused) return;
setIndex((current) => (count ? current % count : 0));
}, [count]);
useEffect(() => {
if (paused || count < 2) return;
const id = window.setInterval(() => {
setIndex((current) => (current + 1) % heroSlides.length);
setIndex((current) => (current + 1) % count);
}, 5000);
return () => window.clearInterval(id);
}, [paused]);
}, [paused, count]);
const sliderStyle = slotAspectStyle(aspectRatio, "21:8");
return (
<section className="container-site pt-5">
<div
className="relative overflow-hidden rounded-2xl aspect-[21/8] min-h-[240px] md:min-h-[360px]"
className="relative overflow-hidden rounded-2xl min-h-[240px] md:min-h-[360px]"
style={sliderStyle ?? { aspectRatio: "21 / 8" }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt=""
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
{slides.length > 0
? slides.map((slide, i) => {
const title = slide.titleFa?.trim();
const english = slide.titleEn?.trim();
const subtitle = slide.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
return (
<div
key={slide.id}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<SlotImage image={slide} fill alt={slotAlt(slide, `اسلاید ${i + 1}`)} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right">
{title ? (
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{subtitle}
</p>
) : null}
</div>
</>
) : null}
</div>
);
})
: heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt={slide.title}
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
/>
</div>
))}
{slides.length === 0 ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{slide.subtitle}
</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
</>
) : null}
{count > 1 ? (
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{Array.from({ length: count }, (_, i) => (
<button
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
))}
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase">
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{heroSlides.map((slide, i) => (
<button
key={slide.title}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
) : null}
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image src={side.image} alt="" fill sizes="600px" className="object-cover object-[28%_center]" />
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
{sideImages.length > 0
? sideImages.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(sideAspectRatio, "13:5")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image
src={side.image}
alt={side.title}
fill
sizes="600px"
className="object-cover object-[28%_center]"
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
+49
View File
@@ -0,0 +1,49 @@
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
type Props = {
image: StaticImageWithUrl;
alt?: string;
className?: string;
imgClassName?: string;
aspectRatio?: string;
fallbackAspect?: string;
fill?: boolean;
};
export function SlotImage({
image,
alt,
className = "",
imgClassName,
aspectRatio,
fallbackAspect,
fill = false,
}: Props) {
const style = fill ? undefined : slotAspectStyle(aspectRatio, fallbackAspect);
const img = (
// Slot URLs come from Meshkee CDN and may not be in next/image remotePatterns.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image.url}
alt={slotAlt(image, alt)}
style={style}
className={
imgClassName ??
(fill
? "absolute inset-0 h-full w-full object-cover"
: "h-auto w-full object-cover")
}
/>
);
if (!image.linkUrl) {
return className ? <div className={className}>{img}</div> : img;
}
return (
<a href={image.linkUrl} className={className || (fill ? "absolute inset-0 block" : "block")}>
{img}
</a>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { API } from "./site";
export type StaticImage = {
id: string;
url?: string | null;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number;
};
export type StaticImageWithUrl = StaticImage & { url: string };
export type StaticImageSlot = {
id: string;
key: string;
pageKey?: string;
kind: "single" | "list";
label?: string;
aspectRatio?: string;
recommendedWidth?: number;
itemCount?: number | null;
sortOrder?: number;
images?: StaticImage[];
};
export type StaticImageCatalogSlot = {
key: string;
label: string;
kind: "single" | "list";
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
/**
* Slot keys the dashboard Refresh button imports from
* GET /meshkee/static-image-slots. Keep aspectRatio + recommendedWidth in
* sync with the live CSS when a slot's size changes.
*/
export const STATIC_IMAGE_KEYS = {
slider: "slider",
bannersBelowSlider: "banners-below-slider",
categories: "categories",
fourBannerSection: "four-banner-section",
threeBannerRow: "three-banner-row",
aboutUsImage: "about-us-image",
} as const;
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
{
key: STATIC_IMAGE_KEYS.slider,
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "21:8",
itemCount: null,
recommendedWidth: 1200,
},
{
key: STATIC_IMAGE_KEYS.bannersBelowSlider,
label: "Banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "13:5",
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.categories,
label: "Homepage categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 160,
},
{
key: STATIC_IMAGE_KEYS.fourBannerSection,
label: "Four banner section",
kind: "list",
pageKey: "home",
aspectRatio: "21:9",
itemCount: 4,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.threeBannerRow,
label: "Three banner row",
kind: "list",
pageKey: "home",
aspectRatio: "12:5",
itemCount: 3,
recommendedWidth: 480,
},
{
key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "About us image",
kind: "single",
pageKey: "home",
aspectRatio: "8:5",
itemCount: 1,
recommendedWidth: 720,
},
];
export function slotAspectStyle(
ratio?: string | null,
fallback?: string,
): { aspectRatio: string } | undefined {
const value = ratio?.trim() || fallback;
if (!value) return undefined;
return { aspectRatio: value.replace(":", " / ") };
}
export function slotAlt(image: StaticImage, fallback = ""): string {
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
}
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
const slot = slots.find((item) => item.key === key);
const raw = slot?.images ?? [];
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const result: StaticImageWithUrl[] = [];
for (const image of sorted) {
const url = typeof image.url === "string" ? image.url.trim() : "";
if (!url) continue;
result.push({ ...image, url });
}
if (slot?.kind === "single") return result.slice(0, 1);
return result;
}
export function firstImageForSlot(
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl | undefined {
return imagesForSlot(slots, key)[0];
}
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
return slots.find((slot) => slot.key === key);
}
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
if (pageKey) url.searchParams.set("pageKey", pageKey);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) return [];
const data = (await response.json()) as { items?: StaticImageSlot[] };
return data.items ?? [];
} catch {
return [];
}
}
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${key}`);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) return null;
return (await response.json()) as StaticImageSlot;
} catch {
return null;
}
}