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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
424e339986
commit
7e8a1ef7dc
+69
@@ -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` → `<img src>`. `linkUrl` → wrap in `<a>`.
|
||||
- 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
+10
-6
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+35
-9
@@ -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<Metadata> {
|
||||
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 (
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<ThemeStyles />
|
||||
</head>
|
||||
<body>
|
||||
<Providers businessId={businessId}>
|
||||
<div className="wrapper home-default-wrapper">
|
||||
<Header />
|
||||
<main className="wb-content">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
+118
-6
@@ -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 (
|
||||
<>
|
||||
<HeroSection />
|
||||
<OnlineProgramSection />
|
||||
<h1 className="sr-only">{siteNameLong}</h1>
|
||||
<HeroSection slider={slider} fourIcon={fourIcon} twoBanner={twoBanner} />
|
||||
<OnlineProgramSection banner={onlineProgramBanner} />
|
||||
<ProductsSection />
|
||||
<ServicesSection />
|
||||
<MedicinalPlantsSection />
|
||||
<AboutSection />
|
||||
<ServicesSection fourLinkIcon={fourLinkIcon} />
|
||||
<MedicinalPlantsSection banner={medicalPlantsBanner} twoBanner={twoBannerLower} />
|
||||
<AboutSection banner={aboutUsBanner} />
|
||||
<ArticlesSection />
|
||||
<VideosSection />
|
||||
<HomeSwipers />
|
||||
|
||||
@@ -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 (
|
||||
<div className="wb-frame" id="about-section">
|
||||
<section
|
||||
@@ -60,12 +70,20 @@ export default function AboutSection() {
|
||||
</div>
|
||||
<div className="col-lg-6 order-force-1">
|
||||
<div className="bg-img text-center">
|
||||
<img
|
||||
{bannerImage ? (
|
||||
<SlotImage
|
||||
className="mt-5 mr-5"
|
||||
style={{ height: "450px" }}
|
||||
alt="درباره سیف طب"
|
||||
src="/images/sections/about-bg.png"
|
||||
style={{
|
||||
height: ratio ? undefined : "450px",
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||
}}
|
||||
src={bannerImage.image}
|
||||
alt={bannerImage.title || "درباره سیف طب"}
|
||||
fallbackSrc={bannerImage.fallbackSrc}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 <i className="WMi-user-linear btn-cart" aria-hidden />;
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<i
|
||||
className={`fa fa-angle-down account-menu__chevron${open ? " open" : ""}`}
|
||||
aria-hidden
|
||||
style={{ lineHeight: 0, fontSize: "12px" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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<HTMLAnchorElement>) {
|
||||
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 (
|
||||
<a
|
||||
href={customerPortalUrl}
|
||||
className="account-menu account-menu--login d-inline-flex color-black align-items-center"
|
||||
aria-label="ورود"
|
||||
title="ورود"
|
||||
>
|
||||
<UserIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = userDisplayName(user);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="account-menu account-menu--user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="account-menu__trigger d-inline-flex color-black align-items-center"
|
||||
>
|
||||
<UserIcon />
|
||||
<span className="account-menu__name">{displayName} عزیز</span>
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div role="menu" className="account-menu__dropdown">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${customerPortalUrl}/profile`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
پروفایل من
|
||||
</a>
|
||||
{isWebsiteAdmin ? (
|
||||
<a role="menuitem" href={businessDashboardUrl} onClick={openBusinessDashboard}>
|
||||
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="account-menu__logout"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,9 @@ export default function Footer() {
|
||||
<div className="col-md-6 col-lg-6">
|
||||
<div className="widget-item mb-sm-30">
|
||||
<div className="about-widget">
|
||||
<h1 style={{ letterSpacing: "0px", fontWeight: 0, color: "#FFFFFF", fontSize: "20px" }}>
|
||||
<h2 style={{ letterSpacing: "0px", fontWeight: 0, color: "#FFFFFF", fontSize: "20px" }}>
|
||||
سیف طب
|
||||
</h1>
|
||||
</h2>
|
||||
<p
|
||||
style={{ fontWeight: 0, textAlign: "justify", letterSpacing: "0px" }}
|
||||
dangerouslySetInnerHTML={{ __html: footerDescription }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import AccountMenu from "@/components/AccountMenu";
|
||||
import { navLinks } from "@/data/home";
|
||||
|
||||
type SubMenuItem = {
|
||||
@@ -91,9 +92,7 @@ export default function Header() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="d-none d-lg-block">
|
||||
<a className="d-inline-flex color-black align-items-center" href="#">
|
||||
<i className="WMi-user-linear btn-cart" />
|
||||
</a>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="wb-frame" id="slider">
|
||||
@@ -8,17 +20,22 @@ export default function HeroSection() {
|
||||
<div className="home-slider-content">
|
||||
<div className="swiper home-slider-container">
|
||||
<div className="swiper-wrapper">
|
||||
<div className="swiper-slide">
|
||||
{slider.items.map((slide) => (
|
||||
<div key={slide.id} className="swiper-slide">
|
||||
<div className="home-slider-item">
|
||||
<a href="#">
|
||||
<SlotLink href={slide.href}>
|
||||
<div
|
||||
className="bg-thumb bg-img"
|
||||
style={{ backgroundImage: "url(/images/sections/hero-bg.jpg)" }}
|
||||
style={{
|
||||
backgroundImage: `url(${slide.image})`,
|
||||
...(sliderRatio ? { aspectRatio: sliderRatio } : {}),
|
||||
}}
|
||||
/>
|
||||
<div className="slider-content-area" />
|
||||
</a>
|
||||
</SlotLink>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="swiper-btn-wrap">
|
||||
<div className="swiper-btn-prev">
|
||||
@@ -68,21 +85,31 @@ export default function HeroSection() {
|
||||
<section className="features-area py-lg-5 py-2" style={{ zIndex: 1, position: "relative" }}>
|
||||
<div className="container-xxl">
|
||||
<div className="row">
|
||||
{quickLinks.map((link) => (
|
||||
<div key={link.id} className="col-lg-3 col-md-6 col-12 mb-sm-20 mb-md-4">
|
||||
{fourIcon.items.map((item, index) => {
|
||||
const fallback = quickLinks[index];
|
||||
const title = item.title || fallback?.title || "";
|
||||
const kicker = item.kicker || fallback?.subtitle || "";
|
||||
const href = item.href || fallback?.href || "#";
|
||||
|
||||
return (
|
||||
<div key={item.id} className="col-lg-3 col-md-6 col-12 mb-sm-20 mb-md-4">
|
||||
<div className="d-flex align-items-start">
|
||||
<div>
|
||||
<img alt={link.title} src={link.image} />
|
||||
<SlotImage
|
||||
src={item.image}
|
||||
alt={title}
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-10">
|
||||
<a href={link.href} title={link.title}>
|
||||
<SlotLink href={href} className="d-block" title={title}>
|
||||
<p
|
||||
className="mb-0"
|
||||
style={{ letterSpacing: "0px", fontWeight: 600, color: "#16171B" }}
|
||||
>
|
||||
{link.title}
|
||||
{title}
|
||||
</p>
|
||||
</a>
|
||||
</SlotLink>
|
||||
<p
|
||||
className="mb-0"
|
||||
style={{
|
||||
@@ -93,12 +120,13 @@ export default function HeroSection() {
|
||||
color: "#C1C1C1",
|
||||
}}
|
||||
>
|
||||
{link.subtitle}
|
||||
{kicker}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -108,21 +136,22 @@ export default function HeroSection() {
|
||||
<section className="classical-workout-area">
|
||||
<div className="container-xxl">
|
||||
<div className="row align-items-stretch">
|
||||
<div className="col-lg-6 col-md-6 col-12 mb-3 mb-md-0">
|
||||
<a href="#online-program" className="d-block h-100 promo-banner">
|
||||
<img width="100%" alt="" src="/images/sections/online-program.png" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="col-lg-6 col-md-6 col-12">
|
||||
<a href="#products" className="d-block h-100 promo-banner">
|
||||
<img width="100%" alt="" src="/images/sections/online-program-mobile.png" />
|
||||
</a>
|
||||
{twoBanner.items.map((item) => (
|
||||
<div key={item.id} className="col-lg-6 col-md-6 col-12 mb-3 mb-md-0">
|
||||
<SlotLink href={item.href} className="d-block h-100 promo-banner">
|
||||
<SlotImage
|
||||
src={item.image}
|
||||
alt={item.title || ""}
|
||||
width="100%"
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
/>
|
||||
</SlotLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="wb-frame" id="medicinal-plants">
|
||||
@@ -65,12 +77,20 @@ export default function MedicinalPlantsSection() {
|
||||
</div>
|
||||
<div className="col-lg-6 order-force-1">
|
||||
<div className="bg-img text-center">
|
||||
<img
|
||||
{bannerImage ? (
|
||||
<SlotImage
|
||||
className="mt-5 mr-5"
|
||||
style={{ height: "450px" }}
|
||||
alt="گیاهان دارویی"
|
||||
src="/images/sections/medicinal-plants.png"
|
||||
style={{
|
||||
height: ratio ? undefined : "450px",
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||
}}
|
||||
src={bannerImage.image}
|
||||
alt={bannerImage.title || "گیاهان دارویی"}
|
||||
fallbackSrc={bannerImage.fallbackSrc}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,16 +102,18 @@ export default function MedicinalPlantsSection() {
|
||||
<section className="classical-workout-area">
|
||||
<div className="container-xxl">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 col-md-6">
|
||||
<a href="#products" title="لوازم ورزشی">
|
||||
<img width="100%" alt="لوازم ورزشی" src="/images/sections/medicinal-plants-bg.png" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="col-lg-6 col-md-6">
|
||||
<a href="#products" title="لوازم ورزشی">
|
||||
<img width="100%" alt="لوازم ورزشی" src="/images/sections/about.png" />
|
||||
</a>
|
||||
{twoBanner.items.map((item) => (
|
||||
<div key={item.id} className="col-lg-6 col-md-6">
|
||||
<SlotLink href={item.href} title={item.title || "لوازم ورزشی"}>
|
||||
<SlotImage
|
||||
src={item.image}
|
||||
alt={item.title || "لوازم ورزشی"}
|
||||
width="100%"
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
/>
|
||||
</SlotLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<div className="wb-frame" id="section2">
|
||||
<section
|
||||
@@ -59,12 +69,20 @@ export default function OnlineProgramSection() {
|
||||
</div>
|
||||
<div className="col-lg-6 order-force-1">
|
||||
<div className="bg-img text-center">
|
||||
<img
|
||||
{image ? (
|
||||
<SlotImage
|
||||
className="mr-5"
|
||||
style={{ height: "450px" }}
|
||||
alt="برنامه آنلاین"
|
||||
src="/images/sections/rehab-equipment.png"
|
||||
style={{
|
||||
height: ratio ? undefined : "450px",
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||
}}
|
||||
src={image.image}
|
||||
alt={image.title || "برنامه آنلاین"}
|
||||
fallbackSrc={image.fallbackSrc}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "@/lib/session";
|
||||
|
||||
export default function Providers({
|
||||
businessId,
|
||||
children,
|
||||
}: {
|
||||
businessId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="wb-frame" id="section">
|
||||
<section className="features-area py-lg-5 py-2">
|
||||
@@ -13,14 +20,24 @@ export default function ServicesSection() {
|
||||
style={{ backgroundImage: "url(/images/sections/products-banner.png)" }}
|
||||
>
|
||||
<div className="row row-gutter-0">
|
||||
{serviceCards.map((card) => (
|
||||
<div key={card.id} className="col-md-6">
|
||||
{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 (
|
||||
<div key={item.id} className="col-md-6">
|
||||
<div className="featured-item">
|
||||
<div className="featured-icon">
|
||||
<img alt={card.title} src={card.image} />
|
||||
<SlotImage
|
||||
src={item.image}
|
||||
alt={title}
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="featured-info">
|
||||
<a href={card.href} title={card.title}>
|
||||
<SlotLink href={href} title={title}>
|
||||
<h2
|
||||
style={{
|
||||
letterSpacing: "0px",
|
||||
@@ -30,7 +47,7 @@ export default function ServicesSection() {
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
@@ -41,13 +58,14 @@ export default function ServicesSection() {
|
||||
fontSize: "10px",
|
||||
}}
|
||||
>
|
||||
{card.subtitle}
|
||||
{kicker}
|
||||
</p>
|
||||
</a>
|
||||
</SlotLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="featured-discount-item">
|
||||
|
||||
@@ -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
|
||||
<img
|
||||
src={resolved}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<a href={href} className={className} title={title} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className} title={title}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(/\/$/, "");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
|
||||
|
||||
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 60): Promise<T> {
|
||||
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<WebsiteFavicon>(tenantPath("/website/favicon"), defaultFavicon);
|
||||
}
|
||||
|
||||
export async function getBusinessInfo() {
|
||||
return meshkeeGet<BusinessInfo | null>(tenantPath("/website/business-info"), null);
|
||||
}
|
||||
|
||||
export async function getBusinessId() {
|
||||
const business = await getBusinessInfo();
|
||||
return business?.id != null ? String(business.id) : "";
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<AuthUser> & { 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<SessionContextValue | null>(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<AuthUser | null>(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 <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
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 || "کاربر";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<string, string[]> = {
|
||||
"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<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