From 7e8a1ef7dcbd127e8d0430342abc9f6de9cce95e Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 1 Sep 2026 11:22:02 +0330 Subject: [PATCH] Wire Meshkee static images, SEO, and customer account menu. Connect homepage image slots and favicon to the API, add sitemap config and page metadata, and implement header login with profile/admin dropdown. Co-authored-by: Cursor --- CONTEXT.md | 69 +++++ next.config.ts | 7 + package.json | 1 + scripts/generate-sitemap-config.mjs | 96 +++++++ src/app/about/page.tsx | 16 +- src/app/api/auth/handoff/route.ts | 17 ++ src/app/api/auth/me/route.ts | 10 + src/app/contact/page.tsx | 16 +- src/app/globals.css | 87 +++++++ src/app/layout.tsx | 54 +++- src/app/meshkee/sitemap-config.json/route.ts | 17 ++ src/app/meshkee/static-image-slots/route.ts | 16 ++ src/app/page.tsx | 124 ++++++++- src/components/AboutSection.tsx | 32 ++- src/components/AccountMenu.tsx | 139 ++++++++++ src/components/Footer.tsx | 4 +- src/components/Header.tsx | 5 +- src/components/HeroSection.tsx | 123 +++++---- src/components/MedicinalPlantsSection.tsx | 56 ++-- src/components/OnlineProgramSection.tsx | 32 ++- src/components/Providers.tsx | 13 + src/components/ServicesSection.tsx | 86 ++++--- src/components/SlotImage.tsx | 36 +++ src/components/SlotLink.tsx | 34 +++ src/data/site.ts | 16 ++ src/lib/config.ts | 22 ++ src/lib/meshkee-server.ts | 42 +++ src/lib/meshkee.ts | 55 ++++ src/lib/seo.ts | 44 ++++ src/lib/session.tsx | 188 ++++++++++++++ src/lib/shared-auth-cookie.ts | 123 +++++++++ src/lib/sitemap-config.generated.json | 20 ++ src/lib/static-images.ts | 258 +++++++++++++++++++ 33 files changed, 1708 insertions(+), 150 deletions(-) create mode 100644 CONTEXT.md create mode 100644 scripts/generate-sitemap-config.mjs create mode 100644 src/app/api/auth/handoff/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/meshkee/sitemap-config.json/route.ts create mode 100644 src/app/meshkee/static-image-slots/route.ts create mode 100644 src/components/AccountMenu.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/components/SlotImage.tsx create mode 100644 src/components/SlotLink.tsx create mode 100644 src/data/site.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/meshkee-server.ts create mode 100644 src/lib/meshkee.ts create mode 100644 src/lib/seo.ts create mode 100644 src/lib/session.tsx create mode 100644 src/lib/shared-auth-cookie.ts create mode 100644 src/lib/sitemap-config.generated.json create mode 100644 src/lib/static-images.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9c27eca --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,69 @@ +# Safeteb — Meshkee website context + +Next.js storefront for **safeteb.com** (RTL, Persian). Uses the Meshkee Website API for CMS-managed static images and favicon. + +## Environment + +| Variable | Purpose | +|----------|---------| +| `NEXT_PUBLIC_DOMAIN` or `NEXT_PUBLIC_WEBSITE_DOMAIN` | Apex domain (`safeteb.com`) | +| `NEXT_PUBLIC_API_BASE_URL` or `NEXT_PUBLIC_MESHKEE_API_BASE` | API base (`https://api.meshkee.com/api/v1`) | +| `NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL` | Customer portal URL | + +## Meshkee integration + +### Static images + +- **Fetch:** `GET /tenants/safeteb.com/website/static-images?pageKey=home` +- **Catalog (dashboard refresh):** `GET /meshkee/static-image-slots` +- **Implementation:** `src/lib/static-images.ts` + homepage in `src/app/page.tsx` + +Homepage slot keys (match by `key`): + +| Key | Section | Kind | Count | +|-----|---------|------|-------| +| `slider` | Hero slider | list (duplicatable) | — | +| `four-icon` | Hero quick links | list | 4 | +| `two-banner` | Hero promo row | list | 2 | +| `online-program-banner` | Online program image | single | 1 | +| `four-link-icon` | Services icons | list | 4 | +| `medical-plants-banner` | Medicinal plants image | single | 1 | +| `two-banner` (2nd occurrence) | Lower promo row | list | 2 | +| `about-us-banner` | About section image | single | 1 | + +Rules: +- Use `slot.images` — all images if `kind=list`, first if `kind=single`. +- `url` → ``. `linkUrl` → wrap in ``. +- Empty slot or API failure → keep local placeholder from `public/images/`. +- Use `slot.aspectRatio` from API; catalog ratios in `STATIC_IMAGE_SLOTS` must stay in sync when layout changes. + +### Favicon + +- **Fetch:** `GET /tenants/safeteb.com/website/favicon` → `{ faviconUrl, logoUrl, ... }` +- **Layout:** `src/app/layout.tsx` uses `faviconUrl` when set; otherwise `/favicon.png`. + +### SEO + sitemap + +- **Do not ship** `public/sitemap.xml` or `public/robots.txt` — nginx proxies both to the Meshkee API. +- **Sitemap config:** `GET /meshkee/sitemap-config.json` (generated at build via `scripts/generate-sitemap-config.mjs`). +- **On-page SEO:** unique title + meta description per page (`src/lib/seo.ts`), one H1 per page, meaningful image `alt` text. +- **After deploy:** business dashboard → Website → Settings → Sync sitemap config. + +## Pages + +- `/` — Home (static-image slots wired) +- `/about` — About +- `/contact` — Contact + +## AI prompt reference + +Latest images-placeholder prompt: +https://api.meshkee.com/api/v1/public/ai-prompts/safeteb/images-placeholder/67da3a9c5a81b4c50d53768c + +Latest SEO prompt: +https://api.meshkee.com/api/v1/public/ai-prompts/safeteb/seo/52c03d5a636374e28da417cd + +## Docs + +- Meshkee Website API: https://api.meshkee.com/docs/website +- OpenAPI: https://api.meshkee.com/docs/website/openapi.json diff --git a/next.config.ts b/next.config.ts index 4b51459..e4d1a5f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { // Leaner production runtime: deploy uses .next/standalone and drops node_modules. output: "standalone", + images: { + remotePatterns: [ + { protocol: "https", hostname: "cdn.meshkee.com" }, + { protocol: "https", hostname: "**.parspack.net" }, + { protocol: "https", hostname: "**.parspack.com" }, + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index ecce08d..832e2fe 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "prebuild": "node scripts/generate-sitemap-config.mjs", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..7214d45 --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -0,0 +1,96 @@ +/** + * Scans app page routes and writes src/lib/sitemap-config.generated.json + * for GET /meshkee/sitemap-config.json (Meshkee sitemap sync). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const appDir = path.join(root, "src", "app"); +const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json"); + +const EXCLUDE_SEGMENTS = new Set([ + "api", + "meshkee", + "admin", + "auth", + "login", + "checkout", + "cart", + "account", +]); + +const PRIORITY = { + "/": { changefreq: "daily", priority: 1.0 }, + "/about": { changefreq: "monthly", priority: 0.6 }, + "/contact": { changefreq: "monthly", priority: 0.5 }, +}; + +function websiteDomain() { + return ( + process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || + process.env.WEBSITE_DOMAIN || + process.env.NEXT_PUBLIC_DOMAIN || + "safeteb.com" + ); +} + +function walkPages(dir, segments = []) { + /** @type {string[]} */ + const pages = []; + if (!fs.existsSync(dir)) return pages; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (EXCLUDE_SEGMENTS.has(entry.name)) continue; + if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) { + if (/^\(.+\)$/.test(entry.name)) { + pages.push(...walkPages(full, segments)); + } + continue; + } + pages.push(...walkPages(full, [...segments, entry.name])); + continue; + } + + if ( + entry.name === "page.tsx" || + entry.name === "page.ts" || + entry.name === "page.jsx" || + entry.name === "page.js" + ) { + const routePath = segments.length ? `/${segments.join("/")}` : "/"; + pages.push(routePath); + } + } + + return pages; +} + +function main() { + const paths = [...new Set(walkPages(appDir))].sort((a, b) => { + if (a === "/") return -1; + if (b === "/") return 1; + return a.localeCompare(b); + }); + + const staticPages = paths.map((routePath) => { + const meta = PRIORITY[routePath] ?? { priority: 0.5 }; + return { path: routePath, ...meta }; + }); + + const config = { + baseUrl: `https://${websiteDomain()}`, + staticPages, + }; + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`); +} + +main(); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 8b41545..f0f4c52 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,14 +1,18 @@ -import type { Metadata } from "next"; import Link from "next/link"; import PageHero from "@/components/PageHero"; import { aboutPage } from "@/data/pages"; +import { aboutDescription } from "@/data/site"; +import { pageMetadata } from "@/lib/seo"; -export const metadata: Metadata = { - title: "درباره ما | سیف طب", - description: - "درباره سیف طب؛ مرکز تخصصی حرکات اصلاحی و ورزش‌درمانی تحت مدیریت محمود سیفی، فارغ‌التحصیل دانشگاه خوارزمی", -}; +export async function generateMetadata() { + return pageMetadata({ + title: "درباره ما", + description: aboutDescription, + path: "/about", + image: aboutPage.image, + }); +} export default function AboutPage() { return ( diff --git a/src/app/api/auth/handoff/route.ts b/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..93365f5 --- /dev/null +++ b/src/app/api/auth/handoff/route.ts @@ -0,0 +1,17 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/handoff", { + method: "POST", + token, + }); + + if (!res.ok) { + return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status); + } + + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..53435fb --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,10 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/me", { token }); + if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 902d33c..cc44657 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,14 +1,16 @@ -import type { Metadata } from "next"; - import ContactForm from "@/components/ContactForm"; import PageHero from "@/components/PageHero"; import { contactPage } from "@/data/pages"; +import { contactDescription } from "@/data/site"; +import { pageMetadata } from "@/lib/seo"; -export const metadata: Metadata = { - title: "تماس با ما | سیف طب", - description: - "راه‌های ارتباطی و تماس با فروشگاه آنلاین سیف طب؛ تلفن، شبکه‌های اجتماعی و فرم ارسال پیام", -}; +export async function generateMetadata() { + return pageMetadata({ + title: "تماس با ما", + description: contactDescription, + path: "/contact", + }); +} export default function ContactPage() { return ( diff --git a/src/app/globals.css b/src/app/globals.css index 41ecdb4..db62796 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -653,3 +653,90 @@ body { height: 360px; width: 100%; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.account-menu { + display: inline-flex; + position: relative; +} + +.account-menu--login { + text-decoration: none; +} + +.account-menu__trigger { + align-items: center; + background: transparent; + border: 0; + cursor: pointer; + display: inline-flex; + font-family: inherit; + font-size: 14px; + font-weight: 600; + gap: 6px; + padding: 0; +} + +.account-menu__name { + max-width: 9.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-menu__chevron { + transition: transform 0.2s ease; +} + +.account-menu__chevron.open { + transform: rotate(180deg); +} + +.account-menu__dropdown { + background: #fff; + border-radius: 8px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.12); + inset-inline-start: 0; + min-width: 12rem; + padding: 8px 0; + position: absolute; + top: calc(100% + 10px); + z-index: 120; +} + +.account-menu__dropdown a, +.account-menu__dropdown button { + background: transparent; + border: 0; + color: #16171b; + cursor: pointer; + display: block; + font-family: inherit; + font-size: 14px; + font-weight: 500; + padding: 10px 16px; + text-align: right; + text-decoration: none; + width: 100%; +} + +.account-menu__dropdown a:hover, +.account-menu__dropdown button:hover { + background: #e7f9fd; + color: #10c6ee; +} + +.account-menu__logout { + color: #b42318; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9764924..7f5d3b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,35 +2,61 @@ import type { Metadata } from "next"; import Footer from "@/components/Footer"; import Header from "@/components/Header"; +import Providers from "@/components/Providers"; import ThemeStyles from "@/components/ThemeStyles"; +import { defaultDescription, siteName } from "@/data/site"; +import { getBusinessId, getBusinessInfo, getWebsiteFavicon } from "@/lib/meshkee"; +import { siteUrl } from "@/lib/seo"; import "./globals.css"; -export const metadata: Metadata = { - title: "صفحه اصلی | سیف طب", - description: - "مجموعه ورزشی پزشکی سیف طب از سال 1390 در زمینه توانبخشی، ورزش درمانی ، رفع آرتروز و سیاتیک و فروش مکمل های ورزشی و گیاهی فعالیت میکند", - icons: { - icon: "/favicon.png", - }, -}; +const defaultFavicon = "/favicon.png"; -export default function RootLayout({ +export async function generateMetadata(): Promise { + const [branding, business] = await Promise.all([getWebsiteFavicon(), getBusinessInfo()]); + const iconUrl = branding.faviconUrl || defaultFavicon; + const brandName = business?.nameFa?.trim() || business?.name?.trim() || siteName; + const description = business?.about?.trim() || defaultDescription; + + return { + metadataBase: new URL(siteUrl), + title: { + default: brandName, + template: `%s | ${brandName}`, + }, + description, + icons: { + icon: iconUrl, + shortcut: iconUrl, + }, + openGraph: { + locale: "fa_IR", + siteName: brandName, + type: "website", + }, + }; +} + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const businessId = await getBusinessId(); + return ( -
-
-
{children}
-
-
+ +
+
+
{children}
+
+
+
); diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..998558b --- /dev/null +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -0,0 +1,17 @@ +import sitemapConfig from "@/lib/sitemap-config.generated.json"; + +const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Cache-Control": "public, max-age=60", + "Content-Type": "application/json; charset=utf-8", +}; + +export async function OPTIONS() { + return new Response(null, { status: 204, headers }); +} + +export async function GET() { + return Response.json(sitemapConfig, { headers }); +} diff --git a/src/app/meshkee/static-image-slots/route.ts b/src/app/meshkee/static-image-slots/route.ts new file mode 100644 index 0000000..0f648ec --- /dev/null +++ b/src/app/meshkee/static-image-slots/route.ts @@ -0,0 +1,16 @@ +import { STATIC_IMAGE_SLOTS } from "@/lib/static-images"; + +const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Cache-Control": "public, max-age=60", +}; + +export async function OPTIONS() { + return new Response(null, { status: 204, headers }); +} + +export async function GET() { + return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 02f00c9..ec91606 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,16 +7,128 @@ import MedicinalPlantsSection from "@/components/MedicinalPlantsSection"; import AboutSection from "@/components/AboutSection"; import ArticlesSection from "@/components/ArticlesSection"; import VideosSection from "@/components/VideosSection"; +import { homeDescription, siteName, siteNameLong } from "@/data/site"; +import { quickLinks, serviceCards } from "@/data/home"; +import { getBusinessInfo } from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; +import { + fetchStaticImageSlots, + overlaySlotImages, + pickSlot, +} from "@/lib/static-images"; + +export async function generateMetadata() { + const business = await getBusinessInfo(); + const brandName = business?.nameFa?.trim() || business?.name?.trim() || siteName; + const description = business?.about?.trim() || homeDescription; + + return pageMetadata({ + title: `${brandName} | ${siteNameLong}`, + description, + path: "/", + absolute: true, + }); +} + +export default async function HomePage() { + const slots = await fetchStaticImageSlots("home"); + + const slider = overlaySlotImages( + pickSlot(slots, "slider"), + [{ id: "hero", image: "/images/sections/hero-bg.jpg", title: "اسلایدر صفحه اصلی" }], + "slider", + ); + + const fourIcon = overlaySlotImages( + pickSlot(slots, "four-icon"), + quickLinks.map((link) => ({ + id: link.id, + image: link.image, + href: link.href, + title: link.title, + kicker: link.subtitle, + })), + "four-icon", + ); + + const twoBanner = overlaySlotImages( + pickSlot(slots, "two-banner"), + [ + { + id: "online-program", + image: "/images/sections/online-program.png", + href: "#online-program", + title: "برنامه آنلاین", + }, + { + id: "online-program-mobile", + image: "/images/sections/online-program-mobile.png", + href: "#products", + title: "محصولات", + }, + ], + "two-banner", + ); + + const onlineProgramBanner = overlaySlotImages( + pickSlot(slots, "online-program-banner"), + [{ id: "rehab-equipment", image: "/images/sections/rehab-equipment.png", title: "برنامه آنلاین" }], + "online-program-banner", + ); + + const fourLinkIcon = overlaySlotImages( + pickSlot(slots, "four-link-icon"), + serviceCards.map((card) => ({ + id: card.id, + image: card.image, + href: card.href, + title: card.title, + kicker: card.subtitle, + })), + "four-link-icon", + ); + + const medicalPlantsBanner = overlaySlotImages( + pickSlot(slots, "medical-plants-banner"), + [{ id: "medicinal-plants", image: "/images/sections/medicinal-plants.png", title: "گیاهان دارویی" }], + "medical-plants-banner", + ); + + const twoBannerLower = overlaySlotImages( + pickSlot(slots, "two-banner", 1), + [ + { + id: "medicinal-plants-bg", + image: "/images/sections/medicinal-plants-bg.png", + href: "#products", + title: "لوازم ورزشی", + }, + { + id: "about-promo", + image: "/images/sections/about.png", + href: "#products", + title: "لوازم ورزشی", + }, + ], + "two-banner", + 1, + ); + + const aboutUsBanner = overlaySlotImages( + pickSlot(slots, "about-us-banner"), + [{ id: "about-bg", image: "/images/sections/about-bg.png", title: "درباره سیف طب" }], + "about-us-banner", + ); -export default function HomePage() { return ( <> - - +

{siteNameLong}

+ + - - - + + + diff --git a/src/components/AboutSection.tsx b/src/components/AboutSection.tsx index 00d1d7f..2b2c8f6 100644 --- a/src/components/AboutSection.tsx +++ b/src/components/AboutSection.tsx @@ -1,6 +1,16 @@ +import SlotImage from "@/components/SlotImage"; import { aboutText } from "@/data/home"; +import type { SlotOverlay } from "@/lib/static-images"; +import { parseAspectRatio } from "@/lib/static-images"; + +type AboutSectionProps = { + banner: SlotOverlay; +}; + +export default function AboutSection({ banner }: AboutSectionProps) { + const bannerImage = banner.items[0]; + const ratio = parseAspectRatio(banner.aspectRatio); -export default function AboutSection() { return (
- درباره سیف طب + {bannerImage ? ( + + ) : null}
diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx new file mode 100644 index 0000000..faccb26 --- /dev/null +++ b/src/components/AccountMenu.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useRef, useState, type MouseEvent } from "react"; + +import { businessDashboardUrl, customerPortalUrl } from "@/lib/config"; +import { useSession, userDisplayName } from "@/lib/session"; + +function UserIcon() { + return ; +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} + +export default function AccountMenu() { + const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession(); + const [open, setOpen] = useState(false); + const [handoffBusy, setHandoffBusy] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onClickOutside = (event: Event) => { + if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + async function openBusinessDashboard(event: MouseEvent) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + event.preventDefault(); + if (handoffBusy) return; + + const baseUrl = businessDashboardUrl; + const popup = window.open("about:blank", "_blank"); + setHandoffBusy(true); + try { + const token = getAccessToken(); + const res = await fetch("/api/auth/handoff", { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!res.ok) throw new Error("handoff failed"); + const data = (await res.json()) as { ticket?: string }; + const url = new URL(baseUrl); + if (data.ticket) url.searchParams.set("handoff", data.ticket); + if (popup) { + popup.opener = null; + popup.location.replace(url.toString()); + } else { + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + } catch { + if (popup) popup.location.replace(baseUrl); + else window.open(baseUrl, "_blank", "noopener,noreferrer"); + } finally { + setHandoffBusy(false); + setOpen(false); + } + } + + if (!ready || !isAuthenticated || !user) { + return ( +
+ + + ); + } + + const displayName = userDisplayName(user); + + return ( +
+ + + {open ? ( +
+ setOpen(false)} + > + پروفایل من + + {isWebsiteAdmin ? ( + + {handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"} + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index ad417f3..1971389 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -17,9 +17,9 @@ export default function Footer() {
-

+

سیف طب -

+

- - - +
diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx index 506e526..43f417f 100644 --- a/src/components/HeroSection.tsx +++ b/src/components/HeroSection.tsx @@ -1,6 +1,18 @@ +import SlotImage from "@/components/SlotImage"; +import SlotLink from "@/components/SlotLink"; import { quickLinks } from "@/data/home"; +import type { SlotOverlay } from "@/lib/static-images"; +import { parseAspectRatio } from "@/lib/static-images"; + +type HeroSectionProps = { + slider: SlotOverlay; + fourIcon: SlotOverlay; + twoBanner: SlotOverlay; +}; + +export default function HeroSection({ slider, fourIcon, twoBanner }: HeroSectionProps) { + const sliderRatio = parseAspectRatio(slider.aspectRatio); -export default function HeroSection() { return ( <>
@@ -8,17 +20,22 @@ export default function HeroSection() {
-
-
- -
-
- + {slider.items.map((slide) => ( +
+
+ +
+
+ +
-
+ ))}
@@ -68,37 +85,48 @@ export default function HeroSection() {
@@ -108,21 +136,22 @@ export default function HeroSection() {
-
- - - -
-
- - - -
+ {twoBanner.items.map((item) => ( +
+ + + +
+ ))}
- ); } diff --git a/src/components/MedicinalPlantsSection.tsx b/src/components/MedicinalPlantsSection.tsx index 92f880e..e1460fe 100644 --- a/src/components/MedicinalPlantsSection.tsx +++ b/src/components/MedicinalPlantsSection.tsx @@ -1,6 +1,18 @@ +import SlotImage from "@/components/SlotImage"; +import SlotLink from "@/components/SlotLink"; import { medicinalPlantsText } from "@/data/home"; +import type { SlotOverlay } from "@/lib/static-images"; +import { parseAspectRatio } from "@/lib/static-images"; + +type MedicinalPlantsSectionProps = { + banner: SlotOverlay; + twoBanner: SlotOverlay; +}; + +export default function MedicinalPlantsSection({ banner, twoBanner }: MedicinalPlantsSectionProps) { + const bannerImage = banner.items[0]; + const ratio = parseAspectRatio(banner.aspectRatio); -export default function MedicinalPlantsSection() { return ( <>
@@ -65,12 +77,20 @@ export default function MedicinalPlantsSection() {
- گیاهان دارویی + {bannerImage ? ( + + ) : null}
@@ -82,16 +102,18 @@ export default function MedicinalPlantsSection() {
-
- - لوازم ورزشی - -
-
- - لوازم ورزشی - -
+ {twoBanner.items.map((item) => ( +
+ + + +
+ ))}
diff --git a/src/components/OnlineProgramSection.tsx b/src/components/OnlineProgramSection.tsx index 66cb880..f0d914d 100644 --- a/src/components/OnlineProgramSection.tsx +++ b/src/components/OnlineProgramSection.tsx @@ -1,6 +1,16 @@ +import SlotImage from "@/components/SlotImage"; import { onlineProgramText } from "@/data/home"; +import type { SlotOverlay } from "@/lib/static-images"; +import { parseAspectRatio } from "@/lib/static-images"; + +type OnlineProgramSectionProps = { + banner: SlotOverlay; +}; + +export default function OnlineProgramSection({ banner }: OnlineProgramSectionProps) { + const image = banner.items[0]; + const ratio = parseAspectRatio(banner.aspectRatio); -export default function OnlineProgramSection() { return (
- برنامه آنلاین + {image ? ( + + ) : null}
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..abe0ebe --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SessionProvider } from "@/lib/session"; + +export default function Providers({ + businessId, + children, +}: { + businessId: string; + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/components/ServicesSection.tsx b/src/components/ServicesSection.tsx index 69879fb..cb84eed 100644 --- a/src/components/ServicesSection.tsx +++ b/src/components/ServicesSection.tsx @@ -1,6 +1,13 @@ +import SlotImage from "@/components/SlotImage"; +import SlotLink from "@/components/SlotLink"; import { serviceCards } from "@/data/home"; +import type { SlotOverlay } from "@/lib/static-images"; -export default function ServicesSection() { +type ServicesSectionProps = { + fourLinkIcon: SlotOverlay; +}; + +export default function ServicesSection({ fourLinkIcon }: ServicesSectionProps) { return (
@@ -13,41 +20,52 @@ export default function ServicesSection() { style={{ backgroundImage: "url(/images/sections/products-banner.png)" }} >
- {serviceCards.map((card) => ( -
-
-
- {card.title} -
-
- -

- {card.title} -

-

- {card.subtitle} -

-
+ {fourLinkIcon.items.map((item, index) => { + const fallback = serviceCards[index]; + const title = item.title || fallback?.title || ""; + const kicker = item.kicker || fallback?.subtitle || ""; + const href = item.href || fallback?.href || "#"; + + return ( +
+
+
+ +
+
+ +

+ {title} +

+

+ {kicker} +

+
+
-
- ))} + ); + })}
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 + {alt} + ); +} 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 []; + } +}