diff --git a/src/components/SlotImage.tsx b/src/components/SlotImage.tsx
new file mode 100644
index 0000000..45214bc
--- /dev/null
+++ b/src/components/SlotImage.tsx
@@ -0,0 +1,36 @@
+import type { CSSProperties } from "react";
+
+type Props = {
+ src: string;
+ alt: string;
+ width?: number | string;
+ height?: number | string;
+ className?: string;
+ style?: CSSProperties;
+ fallbackSrc?: string;
+};
+
+export default function SlotImage({
+ src,
+ alt,
+ width,
+ height,
+ className,
+ style,
+ fallbackSrc,
+}: Props) {
+ const resolved = src || fallbackSrc || "";
+ if (!resolved) return null;
+
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ );
+}
diff --git a/src/components/SlotLink.tsx b/src/components/SlotLink.tsx
new file mode 100644
index 0000000..d3d3071
--- /dev/null
+++ b/src/components/SlotLink.tsx
@@ -0,0 +1,34 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+function isExternal(href: string) {
+ return /^https?:\/\//i.test(href) || href.startsWith("//");
+}
+
+export default function SlotLink({
+ href,
+ className,
+ title,
+ children,
+}: {
+ href?: string;
+ className?: string;
+ title?: string;
+ children: ReactNode;
+}) {
+ if (!href) return <>{children}>;
+
+ if (isExternal(href)) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/data/site.ts b/src/data/site.ts
new file mode 100644
index 0000000..edd7b8f
--- /dev/null
+++ b/src/data/site.ts
@@ -0,0 +1,16 @@
+export const siteName = "سیف طب";
+export const siteNameLong = "گروه ورزشی درمانی سیف طب";
+
+export const defaultDescription =
+ "مجموعه ورزشی پزشکی سیف طب از سال ۱۳۹۰ در زمینه توانبخشی، ورزش درمانی، رفع آرتروز و سیاتیک و فروش مکملهای ورزشی و گیاهی فعالیت میکند.";
+
+export const homeDescription =
+ "فروشگاه آنلاین سیف طب؛ تجهیزات پزشکی ورزشی، برنامه ورزش درمانی آنلاین، مکملهای گیاهی و مشاوره تخصصی برای درمان غیرجراحی دردهای اسکلتی–عضلانی.";
+
+export const aboutDescription =
+ "درباره سیف طب؛ مرکز تخصصی حرکات اصلاحی و ورزشدرمانی تحت مدیریت محمود سیفی، فارغالتحصیل دانشگاه خوارزمی.";
+
+export const contactDescription =
+ "راههای ارتباطی و تماس با فروشگاه آنلاین سیف طب؛ تلفن، شبکههای اجتماعی و فرم ارسال پیام.";
+
+export const defaultOgImage = "/images/sections/hero-bg.jpg";
diff --git a/src/lib/config.ts b/src/lib/config.ts
new file mode 100644
index 0000000..44f5ba3
--- /dev/null
+++ b/src/lib/config.ts
@@ -0,0 +1,22 @@
+export const websiteDomain =
+ process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
+ process.env.WEBSITE_DOMAIN ??
+ process.env.NEXT_PUBLIC_DOMAIN ??
+ "safeteb.com";
+
+export const meshkeeApiBase = (
+ process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
+ process.env.MESHKEE_API_BASE ??
+ process.env.NEXT_PUBLIC_API_BASE_URL ??
+ "https://api.meshkee.com/api/v1"
+).replace(/\/$/, "");
+
+export const customerPortalUrl = (
+ process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ??
+ process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL ??
+ `https://customer.${websiteDomain}`
+).replace(/\/$/, "");
+
+export const businessDashboardUrl = (
+ process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}`
+).replace(/\/$/, "");
diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts
new file mode 100644
index 0000000..97b9bce
--- /dev/null
+++ b/src/lib/meshkee-server.ts
@@ -0,0 +1,42 @@
+import { meshkeeApiBase } from "@/lib/config";
+
+export function getBearerToken(request: Request): string | null {
+ const auth = request.headers.get("authorization");
+ if (auth?.startsWith("Bearer ")) return auth.slice(7);
+ return null;
+}
+
+export async function meshkeeFetch(
+ path: string,
+ init?: RequestInit & { token?: string | null },
+): Promise<{ ok: boolean; status: number; data: unknown }> {
+ const headers = new Headers(init?.headers);
+ if (!headers.has("Content-Type") && init?.body) {
+ headers.set("Content-Type", "application/json");
+ }
+ if (init?.token) {
+ headers.set("Authorization", `Bearer ${init.token}`);
+ }
+
+ const res = await fetch(`${meshkeeApiBase}${path}`, {
+ ...init,
+ headers,
+ });
+
+ let data: unknown = null;
+ try {
+ data = await res.json();
+ } catch {
+ data = null;
+ }
+
+ return { ok: res.ok, status: res.status, data };
+}
+
+export function jsonResponse(data: unknown, status = 200) {
+ return Response.json(data, { status });
+}
+
+export function unauthorizedResponse() {
+ return jsonResponse({ error: "Unauthorized" }, 401);
+}
diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts
new file mode 100644
index 0000000..0f8e5ba
--- /dev/null
+++ b/src/lib/meshkee.ts
@@ -0,0 +1,55 @@
+import { meshkeeApiBase, websiteDomain } from "@/lib/config";
+
+export async function meshkeeGet
(path: string, fallback: T, revalidate = 60): Promise {
+ try {
+ const response = await fetch(`${meshkeeApiBase}${path}`, {
+ next: { revalidate },
+ });
+ if (!response.ok) return fallback;
+ return (await response.json()) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+export function tenantPath(suffix: string) {
+ return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
+}
+
+export type WebsiteFavicon = {
+ faviconUrl: string | null;
+ logoUrl: string | null;
+ logoDarkUrl: string | null;
+ hasDedicatedFavicon: boolean;
+};
+
+export type BusinessInfo = {
+ id: string;
+ name: string;
+ nameFa: string;
+ about: string;
+ vision: string;
+ logoUrl: string | null;
+ logoDarkUrl: string | null;
+ faviconUrl: string | null;
+};
+
+const defaultFavicon: WebsiteFavicon = {
+ faviconUrl: null,
+ logoUrl: null,
+ logoDarkUrl: null,
+ hasDedicatedFavicon: false,
+};
+
+export async function getWebsiteFavicon() {
+ return meshkeeGet(tenantPath("/website/favicon"), defaultFavicon);
+}
+
+export async function getBusinessInfo() {
+ return meshkeeGet(tenantPath("/website/business-info"), null);
+}
+
+export async function getBusinessId() {
+ const business = await getBusinessInfo();
+ return business?.id != null ? String(business.id) : "";
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
new file mode 100644
index 0000000..d2a7587
--- /dev/null
+++ b/src/lib/seo.ts
@@ -0,0 +1,44 @@
+import type { Metadata } from "next";
+
+import { defaultOgImage, siteName } from "@/data/site";
+import { websiteDomain } from "@/lib/config";
+
+export const siteUrl = `https://${websiteDomain}`;
+
+export function pageMetadata({
+ title,
+ description,
+ path = "/",
+ image,
+ absolute = false,
+}: {
+ title: string;
+ description: string;
+ path?: string;
+ image?: string | null;
+ absolute?: boolean;
+}): Metadata {
+ const url = `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
+ const ogImage = image?.startsWith("http") ? image : `${siteUrl}${image || defaultOgImage}`;
+
+ return {
+ title: absolute ? { absolute: title } : title,
+ description,
+ alternates: { canonical: url },
+ openGraph: {
+ type: path === "/" ? "website" : "article",
+ locale: "fa_IR",
+ url,
+ siteName,
+ title,
+ description,
+ images: [{ url: ogImage, alt: title }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ images: [ogImage],
+ },
+ };
+}
diff --git a/src/lib/session.tsx b/src/lib/session.tsx
new file mode 100644
index 0000000..bc953a3
--- /dev/null
+++ b/src/lib/session.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import {
+ clearSharedAuthCookies,
+ hydrateLocalTokensFromSharedCookies,
+ SHARED_ACCESS_TOKEN_KEY,
+ SHARED_REFRESH_TOKEN_KEY,
+ writeSharedAuthCookies,
+} from "@/lib/shared-auth-cookie";
+
+export type AuthBusinessMembership = {
+ id: string;
+ name: string;
+ slug: string;
+ isOwner: boolean;
+ teamRole: string | null;
+};
+
+export type AuthUser = {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ cellNumber?: string;
+ email?: string;
+ roles?: string[];
+ isSuperAdmin?: boolean;
+ businesses?: AuthBusinessMembership[];
+};
+
+type SessionContextValue = {
+ user: AuthUser | null;
+ isAuthenticated: boolean;
+ ready: boolean;
+ businessId: string;
+ isWebsiteAdmin: boolean;
+ logout: () => void;
+ getAccessToken: () => string | null;
+};
+
+function normalizeUser(raw: Partial & { id?: string | number }): AuthUser | null {
+ if (raw.id == null) return null;
+ return {
+ id: String(raw.id),
+ firstName: raw.firstName ?? undefined,
+ lastName: raw.lastName ?? undefined,
+ cellNumber: raw.cellNumber ?? undefined,
+ email: raw.email ?? undefined,
+ roles: Array.isArray(raw.roles) ? raw.roles : undefined,
+ isSuperAdmin: Boolean(raw.isSuperAdmin),
+ businesses: Array.isArray(raw.businesses)
+ ? raw.businesses.map((business) => ({
+ id: String(business.id),
+ name: business.name,
+ slug: business.slug,
+ isOwner: Boolean(business.isOwner),
+ teamRole: business.teamRole ?? null,
+ }))
+ : undefined,
+ };
+}
+
+export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean {
+ if (!user || !businessId) return false;
+ if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
+ return Boolean(
+ user.businesses?.some(
+ (business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
+ ),
+ );
+}
+
+const SessionContext = createContext(null);
+
+function readToken(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function writeToken(key: string, value: string | null) {
+ try {
+ if (value) localStorage.setItem(key, value);
+ else localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+}
+
+export function SessionProvider({
+ children,
+ businessId,
+}: {
+ children: ReactNode;
+ businessId: string;
+}) {
+ const [user, setUser] = useState(null);
+ const [ready, setReady] = useState(false);
+
+ const getAccessToken = useCallback(() => {
+ hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
+ return (
+ readToken(SHARED_ACCESS_TOKEN_KEY) ||
+ readToken("accessToken") ||
+ readToken("meshkeeToken") ||
+ readToken("token")
+ );
+ }, []);
+
+ const clearTokens = useCallback(() => {
+ writeToken(SHARED_ACCESS_TOKEN_KEY, null);
+ writeToken(SHARED_REFRESH_TOKEN_KEY, null);
+ writeToken("accessToken", null);
+ writeToken("meshkeeToken", null);
+ writeToken("token", null);
+ clearSharedAuthCookies();
+ setUser(null);
+ }, []);
+
+ useEffect(() => {
+ const token = getAccessToken();
+ if (!token) {
+ setReady(true);
+ return;
+ }
+
+ const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
+ writeSharedAuthCookies(token, refresh ?? undefined);
+
+ fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
+ .then(async (res) => {
+ if (res.status === 401) {
+ clearTokens();
+ return;
+ }
+ if (!res.ok) return;
+ const data = (await res.json()) as { user?: AuthUser } & AuthUser;
+ const next = normalizeUser(data.user ?? data);
+ if (next) setUser(next);
+ })
+ .catch(() => undefined)
+ .finally(() => setReady(true));
+ }, [clearTokens, getAccessToken]);
+
+ const logout = useCallback(() => {
+ clearTokens();
+ }, [clearTokens]);
+
+ const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
+
+ const value = useMemo(
+ () => ({
+ user,
+ isAuthenticated: Boolean(user),
+ ready,
+ businessId,
+ isWebsiteAdmin,
+ logout,
+ getAccessToken,
+ }),
+ [user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
+ );
+
+ return {children};
+}
+
+export function useSession() {
+ const ctx = useContext(SessionContext);
+ if (!ctx) throw new Error("useSession must be used within SessionProvider");
+ return ctx;
+}
+
+export function userDisplayName(user: AuthUser | null | undefined) {
+ if (!user) return "کاربر";
+ const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
+ return full || user.cellNumber || user.email || "کاربر";
+}
diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts
new file mode 100644
index 0000000..87843c4
--- /dev/null
+++ b/src/lib/shared-auth-cookie.ts
@@ -0,0 +1,123 @@
+/** Shared auth cookies so storefront + customer.{domain} share one login. */
+
+export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
+export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
+
+const CHUNK_SIZE = 2800;
+const MAX_CHUNKS = 24;
+
+function cookieDomain(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ const host = window.location.hostname.toLowerCase();
+ if (host === "localhost" || host === "127.0.0.1") return undefined;
+ const parts = host.split(".").filter(Boolean);
+ if (parts.length < 2) return undefined;
+ return `.${parts.slice(-2).join(".")}`;
+}
+
+function readRawCookie(name: string): string | null {
+ if (typeof document === "undefined") return null;
+ const prefix = `${name}=`;
+ const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
+ if (!match) return null;
+ return match.slice(prefix.length);
+}
+
+function writeRawCookie(name: string, value: string | null) {
+ if (typeof document === "undefined") return;
+ const domain = cookieDomain();
+ if (value == null) {
+ let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
+ if (domain) cookie += `; domain=${domain}`;
+ document.cookie = cookie;
+ return;
+ }
+ const maxAge = 60 * 60 * 24 * 30;
+ let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
+ if (domain) cookie += `; domain=${domain}`;
+ document.cookie = cookie;
+}
+
+function clearChunkedCookie(name: string) {
+ writeRawCookie(name, null);
+ const n = Number(readRawCookie(`${name}_n`) || "0");
+ writeRawCookie(`${name}_n`, null);
+ for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
+ writeRawCookie(`${name}_${i}`, null);
+ }
+}
+
+function writeCookie(name: string, value: string | null) {
+ clearChunkedCookie(name);
+ if (value == null || value === "") return;
+
+ const encoded = encodeURIComponent(value);
+ if (encoded.length <= 3500) {
+ writeRawCookie(name, encoded);
+ return;
+ }
+
+ const chunks: string[] = [];
+ for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
+ chunks.push(encoded.slice(i, i + CHUNK_SIZE));
+ }
+ writeRawCookie(`${name}_n`, String(chunks.length));
+ chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
+}
+
+function readCookie(name: string): string | null {
+ const n = Number(readRawCookie(`${name}_n`) || "0");
+ if (n > 0) {
+ let encoded = "";
+ for (let i = 0; i < n; i++) {
+ const part = readRawCookie(`${name}_${i}`);
+ if (part == null) return null;
+ encoded += part;
+ }
+ try {
+ return decodeURIComponent(encoded);
+ } catch {
+ return null;
+ }
+ }
+
+ const raw = readRawCookie(name);
+ if (raw == null) return null;
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return null;
+ }
+}
+
+export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
+ writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
+ if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
+}
+
+export function clearSharedAuthCookies() {
+ writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
+ writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
+}
+
+export function readSharedAccessToken(): string | null {
+ return readCookie(SHARED_ACCESS_TOKEN_KEY);
+}
+
+export function readSharedRefreshToken(): string | null {
+ return readCookie(SHARED_REFRESH_TOKEN_KEY);
+}
+
+export function hydrateLocalTokensFromSharedCookies(
+ accessKey = SHARED_ACCESS_TOKEN_KEY,
+ refreshKey = SHARED_REFRESH_TOKEN_KEY,
+): boolean {
+ if (typeof window === "undefined") return false;
+ if (localStorage.getItem(accessKey)) return false;
+ const access = readSharedAccessToken();
+ if (!access) return false;
+ localStorage.setItem(accessKey, access);
+ const refresh = readSharedRefreshToken();
+ if (refresh) localStorage.setItem(refreshKey, refresh);
+ return true;
+}
diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json
new file mode 100644
index 0000000..267c08a
--- /dev/null
+++ b/src/lib/sitemap-config.generated.json
@@ -0,0 +1,20 @@
+{
+ "baseUrl": "https://safeteb.com",
+ "staticPages": [
+ {
+ "path": "/",
+ "changefreq": "daily",
+ "priority": 1
+ },
+ {
+ "path": "/about",
+ "changefreq": "monthly",
+ "priority": 0.6
+ },
+ {
+ "path": "/contact",
+ "changefreq": "monthly",
+ "priority": 0.5
+ }
+ ]
+}
diff --git a/src/lib/static-images.ts b/src/lib/static-images.ts
new file mode 100644
index 0000000..c48b2a5
--- /dev/null
+++ b/src/lib/static-images.ts
@@ -0,0 +1,258 @@
+import type { CSSProperties } from "react";
+
+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;
+};
+
+export type SlotOverlay = {
+ items: SlotMediaItem[];
+ aspectRatio?: string;
+};
+
+/**
+ * Homepage slot catalog published at GET /meshkee/static-image-slots.
+ * Ratios match the current homepage layout. 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: "1920:600",
+ itemCount: null,
+ recommendedWidth: 1920,
+ },
+ {
+ key: "four-icon",
+ label: "Four quick-link icons",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "1:1",
+ itemCount: 4,
+ recommendedWidth: 57,
+ },
+ {
+ key: "two-banner",
+ label: "Two promo banners (hero)",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "2172:724",
+ itemCount: 2,
+ recommendedWidth: 1086,
+ },
+ {
+ key: "online-program-banner",
+ label: "Online program banner",
+ kind: "single",
+ pageKey: "home",
+ aspectRatio: "569:574",
+ itemCount: 1,
+ recommendedWidth: 569,
+ },
+ {
+ key: "four-link-icon",
+ label: "Four service link icons",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "1:1",
+ itemCount: 4,
+ recommendedWidth: 57,
+ },
+ {
+ key: "medical-plants-banner",
+ label: "Medicinal plants banner",
+ kind: "single",
+ pageKey: "home",
+ aspectRatio: "569:574",
+ itemCount: 1,
+ recommendedWidth: 569,
+ },
+ {
+ key: "two-banner",
+ label: "Two promo banners (lower row)",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "5:3",
+ itemCount: 2,
+ recommendedWidth: 500,
+ },
+ {
+ key: "about-us-banner",
+ label: "About us banner",
+ kind: "single",
+ pageKey: "home",
+ aspectRatio: "569:574",
+ itemCount: 1,
+ recommendedWidth: 569,
+ },
+];
+
+const SLOT_ALIASES: Record = {
+ "two-banner:1": ["two-banner-lower", "two-banner-2"],
+};
+
+export function catalogSlot(key: string) {
+ const baseKey = key.split(":")[0];
+ return STATIC_IMAGE_SLOTS.find((slot) => slot.key === baseKey);
+}
+
+export function catalogSlots(key: string) {
+ const baseKey = key.split(":")[0];
+ return STATIC_IMAGE_SLOTS.filter((slot) => slot.key === baseKey);
+}
+
+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 slotRatioStyle(ratio?: string | null): CSSProperties | undefined {
+ const parsed = parseAspectRatio(ratio);
+ return parsed ? ({ ["--slot-ratio"]: parsed } as CSSProperties) : undefined;
+}
+
+export function pickSlot(slots: StaticImageSlot[], key: string, occurrence = 0) {
+ const baseKey = key.split(":")[0];
+ const matches = slots.filter((slot) => slot.key === baseKey);
+ if (matches[occurrence]) return matches[occurrence];
+
+ const aliasKey = occurrence > 0 ? `${baseKey}:${occurrence}` : baseKey;
+ const aliases = SLOT_ALIASES[aliasKey] ?? SLOT_ALIASES[baseKey];
+ if (!aliases) return matches[0];
+ return slots.find((slot) => aliases.includes(slot.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,
+ occurrence = 0,
+): SlotOverlay {
+ const catalogMatches = catalogSlots(key);
+ const catalog = catalogMatches[occurrence] ?? catalogMatches[0] ?? 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 {
+ 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 [];
+ }
+}