Initial commit: BRB Store Next.js storefront with Meshkee image slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
|
||||
|
||||
export type SlotKind = "single" | "list";
|
||||
|
||||
export type SlotImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
subtext?: string | null;
|
||||
linkUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export type StaticImageSlotCatalogItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: SlotKind;
|
||||
pageKey: string;
|
||||
aspectRatio: string;
|
||||
itemCount: number | null;
|
||||
recommendedWidth: number;
|
||||
};
|
||||
|
||||
export type StaticImageSlot = StaticImageSlotCatalogItem & {
|
||||
images: SlotImage[];
|
||||
};
|
||||
|
||||
export type SlotMediaItem = {
|
||||
id: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
title?: string;
|
||||
kicker?: string;
|
||||
text?: string;
|
||||
fallbackSrc?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Homepage slot catalog published at GET /meshkee/static-image-slots.
|
||||
* Ratios match the live homepage at max-w-[1280px]. If a section’s CSS size
|
||||
* or aspect ratio changes, update this catalog in the same change — Meshkee
|
||||
* reads it as the static-image source of truth.
|
||||
*/
|
||||
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
|
||||
{
|
||||
key: "slider",
|
||||
label: "Homepage slider",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: null,
|
||||
recommendedWidth: 1280,
|
||||
},
|
||||
{
|
||||
key: "three-icon-image",
|
||||
label: "Three service icons",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 128,
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
label: "Category carousel",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "14:23",
|
||||
itemCount: null,
|
||||
recommendedWidth: 280,
|
||||
},
|
||||
{
|
||||
key: "about-us-image",
|
||||
label: "About us image",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "4:3",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "five-banner-section",
|
||||
label: "Five banner section",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: 5,
|
||||
recommendedWidth: 606,
|
||||
},
|
||||
{
|
||||
key: "two-banner-row",
|
||||
label: "Two banner row",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 606,
|
||||
},
|
||||
{
|
||||
key: "full-row-image",
|
||||
label: "Full-row contact banner",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "32:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 1920,
|
||||
},
|
||||
{
|
||||
key: "two-branch-image",
|
||||
label: "Two branch images",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 606,
|
||||
},
|
||||
];
|
||||
|
||||
export function catalogSlot(key: string) {
|
||||
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ratio?: string | null) {
|
||||
if (!ratio) return undefined;
|
||||
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
|
||||
if (!match) return undefined;
|
||||
return `${match[1]} / ${match[2]}`;
|
||||
}
|
||||
|
||||
export function pickSlot(slots: StaticImageSlot[], key: string) {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
function normalizeImages(images: SlotImage[] | undefined) {
|
||||
return [...(images ?? [])]
|
||||
.filter((image) => Boolean(image?.url))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
|
||||
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
|
||||
if (!slot) return [];
|
||||
const images = normalizeImages(slot.images);
|
||||
if (slot.kind === "single") return images.slice(0, 1);
|
||||
return images;
|
||||
}
|
||||
|
||||
function toMedia(image: SlotImage, fallback?: SlotMediaItem): SlotMediaItem {
|
||||
return {
|
||||
id: String(image.id),
|
||||
image: image.url,
|
||||
href: image.linkUrl?.trim() || fallback?.href,
|
||||
title: image.titleFa?.trim() || fallback?.title,
|
||||
kicker: image.titleEn?.trim() || fallback?.kicker,
|
||||
text: image.subtext?.trim() || fallback?.text,
|
||||
fallbackSrc: fallback?.image || fallback?.fallbackSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export function overlaySlotImages(
|
||||
slot: StaticImageSlot | undefined,
|
||||
fallbacks: SlotMediaItem[],
|
||||
key: string,
|
||||
): { items: SlotMediaItem[]; aspectRatio?: string } {
|
||||
const catalog = catalogSlot(key);
|
||||
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
|
||||
const kind = slot?.kind ?? catalog?.kind ?? "list";
|
||||
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
|
||||
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
|
||||
|
||||
if (kind === "single") {
|
||||
const image = apiImages[0];
|
||||
const fallback = fallbacks[0];
|
||||
return {
|
||||
aspectRatio,
|
||||
items: image ? [toMedia(image, fallback)] : fallbacks.slice(0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
if (apiImages.length === 0) {
|
||||
return {
|
||||
aspectRatio,
|
||||
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
if (itemCount) {
|
||||
return {
|
||||
aspectRatio,
|
||||
items: Array.from({ length: itemCount }, (_, index) => {
|
||||
const image = apiImages[index];
|
||||
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
|
||||
return image ? toMedia(image, fallback) : fallback;
|
||||
}).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
aspectRatio,
|
||||
items: apiImages.map((image, index) => toMedia(image, fallbacks[index])),
|
||||
};
|
||||
}
|
||||
|
||||
function readItems(payload: unknown): StaticImageSlot[] {
|
||||
if (Array.isArray(payload)) return payload as StaticImageSlot[];
|
||||
if (payload && typeof payload === "object") {
|
||||
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
|
||||
return record.items ?? record.slots ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
|
||||
const url = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
return readItems(await response.json());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user