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:
co-authored by
Cursor
parent
46e4835360
commit
49b83223f1
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
+16
-2
@@ -1,27 +1,41 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { PageHero } from "@/components/PageHero";
|
import { PageHero } from "@/components/PageHero";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { SITE } from "@/lib/site";
|
import { SITE } from "@/lib/site";
|
||||||
|
import {
|
||||||
|
STATIC_IMAGE_KEYS,
|
||||||
|
firstImageForSlot,
|
||||||
|
getStaticImageSlot,
|
||||||
|
slotAlt,
|
||||||
|
} from "@/lib/static-images";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `درباره ما | ${SITE.nameFa}`,
|
title: `درباره ما | ${SITE.nameFa}`,
|
||||||
description: SITE.about,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title="درباره ما" english="About Us" />
|
<PageHero title="درباره ما" english="About Us" />
|
||||||
<section className="container-site py-10 md:py-14">
|
<section className="container-site py-10 md:py-14">
|
||||||
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
|
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
|
||||||
|
{image ? (
|
||||||
|
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
|
||||||
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src="/images/pages/about-bg.webp"
|
src="/images/pages/about-bg.webp"
|
||||||
alt=""
|
alt={SITE.nameFa}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
sizes="1200px"
|
sizes="1200px"
|
||||||
className="object-cover object-center"
|
className="object-cover object-center"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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
@@ -6,18 +6,46 @@ import { Categories } from "@/components/Categories";
|
|||||||
import { HeroSlider } from "@/components/HeroSlider";
|
import { HeroSlider } from "@/components/HeroSlider";
|
||||||
import { SpecialSale } from "@/components/SpecialSale";
|
import { SpecialSale } from "@/components/SpecialSale";
|
||||||
import { TopSelling } from "@/components/TopSelling";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSlider />
|
<HeroSlider
|
||||||
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
|
||||||
|
aspectRatio={sliderSlot?.aspectRatio}
|
||||||
|
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.bannersBelowSlider)}
|
||||||
|
sideAspectRatio={sideSlot?.aspectRatio}
|
||||||
|
/>
|
||||||
<SpecialSale />
|
<SpecialSale />
|
||||||
<Categories />
|
<Categories images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
|
||||||
<BannerGrid4 />
|
<BannerGrid4
|
||||||
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBannerSection)}
|
||||||
|
aspectRatio={fourSlot?.aspectRatio}
|
||||||
|
/>
|
||||||
<TopSelling />
|
<TopSelling />
|
||||||
<BannerRow3 />
|
<BannerRow3
|
||||||
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||||
|
aspectRatio={threeSlot?.aspectRatio}
|
||||||
|
/>
|
||||||
<Brands />
|
<Brands />
|
||||||
<AboutUs />
|
<AboutUs
|
||||||
|
image={firstImageForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||||
|
aspectRatio={aboutSlot?.aspectRatio}
|
||||||
|
/>
|
||||||
<BlogCarousel />
|
<BlogCarousel />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,17 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { SITE } from "@/lib/site";
|
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 (
|
return (
|
||||||
<section id="about" className="container-site mt-14">
|
<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">
|
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
|
||||||
@@ -14,6 +23,11 @@ export function AboutUs() {
|
|||||||
مشاهده بیشتر
|
مشاهده بیشتر
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
|
||||||
<Image
|
<Image
|
||||||
src="/images/logo/logo.png"
|
src="/images/logo/logo.png"
|
||||||
@@ -23,6 +37,7 @@ export function AboutUs() {
|
|||||||
className="h-auto w-full max-w-sm object-contain"
|
className="h-auto w-full max-w-sm object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
+78
-14
@@ -1,27 +1,79 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { banners3, banners4 } from "@/lib/data";
|
import { banners3, banners4 } from "@/lib/data";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
||||||
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
import { Media } from "./Media";
|
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 (
|
return (
|
||||||
<section className="container-site mt-12">
|
<section className="container-site mt-12">
|
||||||
<div className="grid grid-cols-2 gap-3 md:gap-4">
|
<div className="grid grid-cols-2 gap-3 md:gap-4">
|
||||||
{banners4.map((banner) => (
|
{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">
|
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
|
||||||
<div className="relative aspect-[21/9] min-h-[140px]">
|
<div className="relative aspect-[21/9] min-h-[140px]">
|
||||||
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
|
<Image src={banner.image} alt={banner.title} fill sizes="50vw" className="object-cover object-center" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
|
||||||
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">
|
<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 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">
|
<p
|
||||||
|
dir="ltr"
|
||||||
|
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
|
||||||
|
>
|
||||||
{banner.english}
|
{banner.english}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
|
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
|
||||||
@@ -33,13 +85,25 @@ export function BannerGrid4() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BannerRow3() {
|
export function BannerRow3({ images = [], aspectRatio }: BannerSlotProps) {
|
||||||
return (
|
return (
|
||||||
<section className="container-site mt-12">
|
<section className="container-site mt-12">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
|
||||||
{banners3.map((banner) => (
|
{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">
|
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
|
||||||
<Media src={banner.image} alt="" className="aspect-[2.4/1]" sizes="33vw" />
|
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { categories } from "@/lib/data";
|
import { categories } from "@/lib/data";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
||||||
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
import { DrawFrame } from "./DrawFrame";
|
import { DrawFrame } from "./DrawFrame";
|
||||||
|
|
||||||
export function Categories() {
|
export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
|
||||||
return (
|
return (
|
||||||
<section className="container-site mt-12">
|
<section className="container-site mt-12">
|
||||||
<div className="flex items-end justify-between mb-6">
|
<div className="flex items-end justify-between mb-6">
|
||||||
@@ -13,7 +16,45 @@ export function Categories() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
|
<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) => (
|
{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
|
<Link
|
||||||
key={cat.slug}
|
key={cat.slug}
|
||||||
href={`/products/${cat.slug}`}
|
href={`/products/${cat.slug}`}
|
||||||
@@ -28,7 +69,10 @@ export function Categories() {
|
|||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
|
<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">
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
|
||||||
|
>
|
||||||
{cat.english}
|
{cat.english}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+156
-17
@@ -3,33 +3,101 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { heroSides, heroSlides } from "@/lib/data";
|
import { heroSides, heroSlides } from "@/lib/data";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
||||||
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
|
|
||||||
const overlayStyle = {
|
const overlayStyle = {
|
||||||
background:
|
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%)",
|
"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 [index, setIndex] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const count = slides.length > 0 ? slides.length : heroSlides.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
setIndex((current) => (count ? current % count : 0));
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || count < 2) return;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
setIndex((current) => (current + 1) % heroSlides.length);
|
setIndex((current) => (current + 1) % count);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [paused]);
|
}, [paused, count]);
|
||||||
|
|
||||||
|
const sliderStyle = slotAspectStyle(aspectRatio, "21:8");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="container-site pt-5">
|
<section className="container-site pt-5">
|
||||||
<div
|
<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)}
|
onMouseEnter={() => setPaused(true)}
|
||||||
onMouseLeave={() => setPaused(false)}
|
onMouseLeave={() => setPaused(false)}
|
||||||
>
|
>
|
||||||
{heroSlides.map((slide, i) => (
|
{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
|
<div
|
||||||
key={slide.title}
|
key={slide.title}
|
||||||
aria-hidden={i !== index}
|
aria-hidden={i !== index}
|
||||||
@@ -39,7 +107,7 @@ export function HeroSlider() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={slide.image}
|
src={slide.image}
|
||||||
alt=""
|
alt={slide.title}
|
||||||
fill
|
fill
|
||||||
sizes="1200px"
|
sizes="1200px"
|
||||||
priority={i === 0}
|
priority={i === 0}
|
||||||
@@ -48,8 +116,12 @@ export function HeroSlider() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
|
{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) => (
|
{heroSlides.map((slide, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${slide.title}-copy`}
|
key={`${slide.title}-copy`}
|
||||||
@@ -58,10 +130,15 @@ export function HeroSlider() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
|
<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">
|
<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}
|
{slide.english}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
|
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
|
||||||
|
{slide.subtitle}
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={slide.href}
|
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]"
|
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]"
|
||||||
@@ -70,11 +147,14 @@ export function HeroSlider() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
|
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
|
||||||
{heroSlides.map((slide, i) => (
|
{Array.from({ length: count }, (_, i) => (
|
||||||
<button
|
<button
|
||||||
key={slide.title}
|
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`اسلاید ${i + 1}`}
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
onClick={() => setIndex(i)}
|
onClick={() => setIndex(i)}
|
||||||
@@ -82,18 +162,77 @@ export function HeroSlider() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{heroSides.map((side) => (
|
{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">
|
<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]">
|
<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]" />
|
<Image
|
||||||
|
src={side.image}
|
||||||
|
alt={side.title}
|
||||||
|
fill
|
||||||
|
sizes="600px"
|
||||||
|
className="object-cover object-[28%_center]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
|
<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">
|
<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 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">
|
<p
|
||||||
|
dir="ltr"
|
||||||
|
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
|
||||||
|
>
|
||||||
{side.english}
|
{side.english}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
|
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user