From 49b83223f17fbdc2a6005eb51e1f10cdc7153dfc Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 25 Aug 2026 17:54:10 +0330 Subject: [PATCH] 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 --- .cursor/rules/static-image-slots.mdc | 17 ++ Ai-data/CONTEXT.md | 44 ++++ Ai-data/prompt-images-slot.txt | 20 ++ app/aboutus/page.tsx | 32 ++- app/meshkee/static-image-slots/route.ts | 14 ++ app/page.tsx | 40 +++- components/AboutUs.tsx | 35 ++- components/Banners.tsx | 120 +++++++--- components/Categories.tsx | 90 ++++++-- components/HeroSlider.tsx | 281 ++++++++++++++++++------ components/SlotImage.tsx | 49 +++++ lib/static-images.ts | 171 ++++++++++++++ 12 files changed, 766 insertions(+), 147 deletions(-) create mode 100644 .cursor/rules/static-image-slots.mdc create mode 100644 Ai-data/CONTEXT.md create mode 100644 Ai-data/prompt-images-slot.txt create mode 100644 app/meshkee/static-image-slots/route.ts create mode 100644 components/SlotImage.tsx create mode 100644 lib/static-images.ts diff --git a/.cursor/rules/static-image-slots.mdc b/.cursor/rules/static-image-slots.mdc new file mode 100644 index 0000000..6e70996 --- /dev/null +++ b/.cursor/rules/static-image-slots.mdc @@ -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`. diff --git a/Ai-data/CONTEXT.md b/Ai-data/CONTEXT.md new file mode 100644 index 0000000..b7c663a --- /dev/null +++ b/Ai-data/CONTEXT.md @@ -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` → ``. `linkUrl` → wrap in ``. +- 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`. diff --git a/Ai-data/prompt-images-slot.txt b/Ai-data/prompt-images-slot.txt new file mode 100644 index 0000000..69a8295 --- /dev/null +++ b/Ai-data/prompt-images-slot.txt @@ -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 → . linkUrl → wrap in . 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) \ No newline at end of file diff --git a/app/aboutus/page.tsx b/app/aboutus/page.tsx index 91fdf9f..69ea1c6 100644 --- a/app/aboutus/page.tsx +++ b/app/aboutus/page.tsx @@ -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 ( <>
- + {image ? ( + + ) : ( + {SITE.nameFa} + )}
- + - - + + - + - + ); diff --git a/components/AboutUs.tsx b/components/AboutUs.tsx index c43e88b..30a2a23 100644 --- a/components/AboutUs.tsx +++ b/components/AboutUs.tsx @@ -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 (
@@ -14,15 +23,21 @@ export function AboutUs() { مشاهده بیشتر
-
- {SITE.nameFa} -
+ {image ? ( +
+ +
+ ) : ( +
+ {SITE.nameFa} +
+ )}
); diff --git a/components/Banners.tsx b/components/Banners.tsx index 1358322..5c407ac 100644 --- a/components/Banners.tsx +++ b/components/Banners.tsx @@ -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 (
- {banners4.map((banner) => ( - -
- -
-
-
-

{banner.title}

-

- {banner.english} -

-

{banner.subtitle}

-
- - ))} + {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 = ( +
); } -export function BannerRow3() { +export function BannerRow3({ images = [], aspectRatio }: BannerSlotProps) { return (
- {banners3.map((banner) => ( - - - - ))} + {images.length > 0 + ? images.map((image) => ( +
+ +
+ )) + : banners3.map((banner) => ( + + + + ))}
); diff --git a/components/Categories.tsx b/components/Categories.tsx index e03b922..319dbd0 100644 --- a/components/Categories.tsx +++ b/components/Categories.tsx @@ -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 (
@@ -13,28 +16,69 @@ export function Categories() {
- {categories.map((cat) => ( - - {cat.name} - - {cat.name} - - {cat.english} - - - - - ))} + {images.length > 0 + ? images.map((image) => { + const label = slotAlt(image); + const card = ( +
+
+ +
+ {label || image.subtext ? ( + + {label ? ( + {label} + ) : null} + {image.subtext ? ( + + {image.subtext} + + ) : null} + + ) : null} + +
+ ); + return image.linkUrl ? ( + + {card} + + ) : ( +
{card}
+ ); + }) + : categories.map((cat) => ( + + {cat.name} + + {cat.name} + + {cat.english} + + + + + ))}
); diff --git a/components/HeroSlider.tsx b/components/HeroSlider.tsx index 253de43..fb4ae9d 100644 --- a/components/HeroSlider.tsx +++ b/components/HeroSlider.tsx @@ -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 (
setPaused(true)} onMouseLeave={() => setPaused(false)} > - {heroSlides.map((slide, i) => ( -
- 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 ( +
+ + {hasCopy ? ( + <> +
+
+ {title ? ( +

{title}

+ ) : null} + {english ? ( +

+ {english} +

+ ) : null} + {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ + ) : null} +
+ ); + }) + : heroSlides.map((slide, i) => ( +
+ {slide.title} +
+ ))} + + {slides.length === 0 ? ( + <> +
+ {heroSlides.map((slide, i) => ( +
+

{slide.title}

+

+ {slide.english} +

+

+ {slide.subtitle} +

+ + {slide.button} + +
+ ))} + + ) : null} + + {count > 1 ? ( +
+ {Array.from({ length: count }, (_, i) => ( +
- ))} - -
- - {heroSlides.map((slide, i) => ( -
-

{slide.title}

-

- {slide.english} -

-

{slide.subtitle}

- - {slide.button} - -
- ))} - -
- {heroSlides.map((slide, i) => ( -
+ ) : null}
- {heroSides.map((side) => ( - -
- -
-
-
-

{side.title}

-

- {side.english} -

-

{side.subtitle}

-
- - ))} + {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 = ( +
+ + {hasCopy ? ( + <> +
+
+ {title ? ( +

{title}

+ ) : null} + {english ? ( +

+ {english} +

+ ) : null} + {subtitle ? ( +

{subtitle}

+ ) : null} +
+ + ) : null} +
+ ); + return image.linkUrl ? ( + + {frame} + + ) : ( +
{frame}
+ ); + }) + : heroSides.map((side) => ( + +
+ {side.title} +
+
+
+

{side.title}

+

+ {side.english} +

+

{side.subtitle}

+
+ + ))}
); diff --git a/components/SlotImage.tsx b/components/SlotImage.tsx new file mode 100644 index 0000000..cb1738b --- /dev/null +++ b/components/SlotImage.tsx @@ -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 + {slotAlt(image, + ); + + if (!image.linkUrl) { + return className ?
{img}
: img; + } + + return ( + + {img} + + ); +} diff --git a/lib/static-images.ts b/lib/static-images.ts new file mode 100644 index 0000000..80a13a5 --- /dev/null +++ b/lib/static-images.ts @@ -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 { + 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 { + 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; + } +}