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 = {
|
const nextConfig: NextConfig = {
|
||||||
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||||
|
{ protocol: "https", hostname: "**.parspack.net" },
|
||||||
|
{ protocol: "https", hostname: "**.parspack.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"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 Link from "next/link";
|
||||||
|
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import { aboutPage } from "@/data/pages";
|
import { aboutPage } from "@/data/pages";
|
||||||
|
import { aboutDescription } from "@/data/site";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata() {
|
||||||
title: "درباره ما | سیف طب",
|
return pageMetadata({
|
||||||
description:
|
title: "درباره ما",
|
||||||
"درباره سیف طب؛ مرکز تخصصی حرکات اصلاحی و ورزشدرمانی تحت مدیریت محمود سیفی، فارغالتحصیل دانشگاه خوارزمی",
|
description: aboutDescription,
|
||||||
};
|
path: "/about",
|
||||||
|
image: aboutPage.image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
return (
|
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 ContactForm from "@/components/ContactForm";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import { contactPage } from "@/data/pages";
|
import { contactPage } from "@/data/pages";
|
||||||
|
import { contactDescription } from "@/data/site";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata() {
|
||||||
title: "تماس با ما | سیف طب",
|
return pageMetadata({
|
||||||
description:
|
title: "تماس با ما",
|
||||||
"راههای ارتباطی و تماس با فروشگاه آنلاین سیف طب؛ تلفن، شبکههای اجتماعی و فرم ارسال پیام",
|
description: contactDescription,
|
||||||
};
|
path: "/contact",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -653,3 +653,90 @@ body {
|
|||||||
height: 360px;
|
height: 360px;
|
||||||
width: 100%;
|
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 Footer from "@/components/Footer";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
|
import Providers from "@/components/Providers";
|
||||||
import ThemeStyles from "@/components/ThemeStyles";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const defaultFavicon = "/favicon.png";
|
||||||
title: "صفحه اصلی | سیف طب",
|
|
||||||
description:
|
|
||||||
"مجموعه ورزشی پزشکی سیف طب از سال 1390 در زمینه توانبخشی، ورزش درمانی ، رفع آرتروز و سیاتیک و فروش مکمل های ورزشی و گیاهی فعالیت میکند",
|
|
||||||
icons: {
|
|
||||||
icon: "/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,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const businessId = await getBusinessId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<ThemeStyles />
|
<ThemeStyles />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<Providers businessId={businessId}>
|
||||||
<div className="wrapper home-default-wrapper">
|
<div className="wrapper home-default-wrapper">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="wb-content">{children}</main>
|
<main className="wb-content">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 AboutSection from "@/components/AboutSection";
|
||||||
import ArticlesSection from "@/components/ArticlesSection";
|
import ArticlesSection from "@/components/ArticlesSection";
|
||||||
import VideosSection from "@/components/VideosSection";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSection />
|
<h1 className="sr-only">{siteNameLong}</h1>
|
||||||
<OnlineProgramSection />
|
<HeroSection slider={slider} fourIcon={fourIcon} twoBanner={twoBanner} />
|
||||||
|
<OnlineProgramSection banner={onlineProgramBanner} />
|
||||||
<ProductsSection />
|
<ProductsSection />
|
||||||
<ServicesSection />
|
<ServicesSection fourLinkIcon={fourLinkIcon} />
|
||||||
<MedicinalPlantsSection />
|
<MedicinalPlantsSection banner={medicalPlantsBanner} twoBanner={twoBannerLower} />
|
||||||
<AboutSection />
|
<AboutSection banner={aboutUsBanner} />
|
||||||
<ArticlesSection />
|
<ArticlesSection />
|
||||||
<VideosSection />
|
<VideosSection />
|
||||||
<HomeSwipers />
|
<HomeSwipers />
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
import SlotImage from "@/components/SlotImage";
|
||||||
import { aboutText } from "@/data/home";
|
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 (
|
return (
|
||||||
<div className="wb-frame" id="about-section">
|
<div className="wb-frame" id="about-section">
|
||||||
<section
|
<section
|
||||||
@@ -60,12 +70,20 @@ export default function AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 order-force-1">
|
<div className="col-lg-6 order-force-1">
|
||||||
<div className="bg-img text-center">
|
<div className="bg-img text-center">
|
||||||
<img
|
{bannerImage ? (
|
||||||
|
<SlotImage
|
||||||
className="mt-5 mr-5"
|
className="mt-5 mr-5"
|
||||||
style={{ height: "450px" }}
|
style={{
|
||||||
alt="درباره سیف طب"
|
height: ratio ? undefined : "450px",
|
||||||
src="/images/sections/about-bg.png"
|
width: "auto",
|
||||||
|
maxWidth: "100%",
|
||||||
|
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||||
|
}}
|
||||||
|
src={bannerImage.image}
|
||||||
|
alt={bannerImage.title || "درباره سیف طب"}
|
||||||
|
fallbackSrc={bannerImage.fallbackSrc}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="col-md-6 col-lg-6">
|
||||||
<div className="widget-item mb-sm-30">
|
<div className="widget-item mb-sm-30">
|
||||||
<div className="about-widget">
|
<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
|
<p
|
||||||
style={{ fontWeight: 0, textAlign: "justify", letterSpacing: "0px" }}
|
style={{ fontWeight: 0, textAlign: "justify", letterSpacing: "0px" }}
|
||||||
dangerouslySetInnerHTML={{ __html: footerDescription }}
|
dangerouslySetInnerHTML={{ __html: footerDescription }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import AccountMenu from "@/components/AccountMenu";
|
||||||
import { navLinks } from "@/data/home";
|
import { navLinks } from "@/data/home";
|
||||||
|
|
||||||
type SubMenuItem = {
|
type SubMenuItem = {
|
||||||
@@ -91,9 +92,7 @@ export default function Header() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-none d-lg-block">
|
<div className="d-none d-lg-block">
|
||||||
<a className="d-inline-flex color-black align-items-center" href="#">
|
<AccountMenu />
|
||||||
<i className="WMi-user-linear btn-cart" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
import SlotImage from "@/components/SlotImage";
|
||||||
|
import SlotLink from "@/components/SlotLink";
|
||||||
import { quickLinks } from "@/data/home";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="wb-frame" id="slider">
|
<div className="wb-frame" id="slider">
|
||||||
@@ -8,17 +20,22 @@ export default function HeroSection() {
|
|||||||
<div className="home-slider-content">
|
<div className="home-slider-content">
|
||||||
<div className="swiper home-slider-container">
|
<div className="swiper home-slider-container">
|
||||||
<div className="swiper-wrapper">
|
<div className="swiper-wrapper">
|
||||||
<div className="swiper-slide">
|
{slider.items.map((slide) => (
|
||||||
|
<div key={slide.id} className="swiper-slide">
|
||||||
<div className="home-slider-item">
|
<div className="home-slider-item">
|
||||||
<a href="#">
|
<SlotLink href={slide.href}>
|
||||||
<div
|
<div
|
||||||
className="bg-thumb bg-img"
|
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" />
|
<div className="slider-content-area" />
|
||||||
</a>
|
</SlotLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="swiper-btn-wrap">
|
<div className="swiper-btn-wrap">
|
||||||
<div className="swiper-btn-prev">
|
<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" }}>
|
<section className="features-area py-lg-5 py-2" style={{ zIndex: 1, position: "relative" }}>
|
||||||
<div className="container-xxl">
|
<div className="container-xxl">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{quickLinks.map((link) => (
|
{fourIcon.items.map((item, index) => {
|
||||||
<div key={link.id} className="col-lg-3 col-md-6 col-12 mb-sm-20 mb-md-4">
|
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 className="d-flex align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<img alt={link.title} src={link.image} />
|
<SlotImage
|
||||||
|
src={item.image}
|
||||||
|
alt={title}
|
||||||
|
fallbackSrc={item.fallbackSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-10">
|
<div className="mr-10">
|
||||||
<a href={link.href} title={link.title}>
|
<SlotLink href={href} className="d-block" title={title}>
|
||||||
<p
|
<p
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
style={{ letterSpacing: "0px", fontWeight: 600, color: "#16171B" }}
|
style={{ letterSpacing: "0px", fontWeight: 600, color: "#16171B" }}
|
||||||
>
|
>
|
||||||
{link.title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</SlotLink>
|
||||||
<p
|
<p
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
style={{
|
style={{
|
||||||
@@ -93,12 +120,13 @@ export default function HeroSection() {
|
|||||||
color: "#C1C1C1",
|
color: "#C1C1C1",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{link.subtitle}
|
{kicker}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -108,21 +136,22 @@ export default function HeroSection() {
|
|||||||
<section className="classical-workout-area">
|
<section className="classical-workout-area">
|
||||||
<div className="container-xxl">
|
<div className="container-xxl">
|
||||||
<div className="row align-items-stretch">
|
<div className="row align-items-stretch">
|
||||||
<div className="col-lg-6 col-md-6 col-12 mb-3 mb-md-0">
|
{twoBanner.items.map((item) => (
|
||||||
<a href="#online-program" className="d-block h-100 promo-banner">
|
<div key={item.id} className="col-lg-6 col-md-6 col-12 mb-3 mb-md-0">
|
||||||
<img width="100%" alt="" src="/images/sections/online-program.png" />
|
<SlotLink href={item.href} className="d-block h-100 promo-banner">
|
||||||
</a>
|
<SlotImage
|
||||||
</div>
|
src={item.image}
|
||||||
<div className="col-lg-6 col-md-6 col-12">
|
alt={item.title || ""}
|
||||||
<a href="#products" className="d-block h-100 promo-banner">
|
width="100%"
|
||||||
<img width="100%" alt="" src="/images/sections/online-program-mobile.png" />
|
fallbackSrc={item.fallbackSrc}
|
||||||
</a>
|
/>
|
||||||
|
</SlotLink>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
import SlotImage from "@/components/SlotImage";
|
||||||
|
import SlotLink from "@/components/SlotLink";
|
||||||
import { medicinalPlantsText } from "@/data/home";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="wb-frame" id="medicinal-plants">
|
<div className="wb-frame" id="medicinal-plants">
|
||||||
@@ -65,12 +77,20 @@ export default function MedicinalPlantsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 order-force-1">
|
<div className="col-lg-6 order-force-1">
|
||||||
<div className="bg-img text-center">
|
<div className="bg-img text-center">
|
||||||
<img
|
{bannerImage ? (
|
||||||
|
<SlotImage
|
||||||
className="mt-5 mr-5"
|
className="mt-5 mr-5"
|
||||||
style={{ height: "450px" }}
|
style={{
|
||||||
alt="گیاهان دارویی"
|
height: ratio ? undefined : "450px",
|
||||||
src="/images/sections/medicinal-plants.png"
|
width: "auto",
|
||||||
|
maxWidth: "100%",
|
||||||
|
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||||
|
}}
|
||||||
|
src={bannerImage.image}
|
||||||
|
alt={bannerImage.title || "گیاهان دارویی"}
|
||||||
|
fallbackSrc={bannerImage.fallbackSrc}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,16 +102,18 @@ export default function MedicinalPlantsSection() {
|
|||||||
<section className="classical-workout-area">
|
<section className="classical-workout-area">
|
||||||
<div className="container-xxl">
|
<div className="container-xxl">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-6 col-md-6">
|
{twoBanner.items.map((item) => (
|
||||||
<a href="#products" title="لوازم ورزشی">
|
<div key={item.id} className="col-lg-6 col-md-6">
|
||||||
<img width="100%" alt="لوازم ورزشی" src="/images/sections/medicinal-plants-bg.png" />
|
<SlotLink href={item.href} title={item.title || "لوازم ورزشی"}>
|
||||||
</a>
|
<SlotImage
|
||||||
</div>
|
src={item.image}
|
||||||
<div className="col-lg-6 col-md-6">
|
alt={item.title || "لوازم ورزشی"}
|
||||||
<a href="#products" title="لوازم ورزشی">
|
width="100%"
|
||||||
<img width="100%" alt="لوازم ورزشی" src="/images/sections/about.png" />
|
fallbackSrc={item.fallbackSrc}
|
||||||
</a>
|
/>
|
||||||
|
</SlotLink>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
import SlotImage from "@/components/SlotImage";
|
||||||
import { onlineProgramText } from "@/data/home";
|
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 (
|
return (
|
||||||
<div className="wb-frame" id="section2">
|
<div className="wb-frame" id="section2">
|
||||||
<section
|
<section
|
||||||
@@ -59,12 +69,20 @@ export default function OnlineProgramSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 order-force-1">
|
<div className="col-lg-6 order-force-1">
|
||||||
<div className="bg-img text-center">
|
<div className="bg-img text-center">
|
||||||
<img
|
{image ? (
|
||||||
|
<SlotImage
|
||||||
className="mr-5"
|
className="mr-5"
|
||||||
style={{ height: "450px" }}
|
style={{
|
||||||
alt="برنامه آنلاین"
|
height: ratio ? undefined : "450px",
|
||||||
src="/images/sections/rehab-equipment.png"
|
width: "auto",
|
||||||
|
maxWidth: "100%",
|
||||||
|
...(ratio ? { aspectRatio: ratio, height: "auto", maxHeight: "450px" } : {}),
|
||||||
|
}}
|
||||||
|
src={image.image}
|
||||||
|
alt={image.title || "برنامه آنلاین"}
|
||||||
|
fallbackSrc={image.fallbackSrc}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { 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 (
|
return (
|
||||||
<div className="wb-frame" id="section">
|
<div className="wb-frame" id="section">
|
||||||
<section className="features-area py-lg-5 py-2">
|
<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)" }}
|
style={{ backgroundImage: "url(/images/sections/products-banner.png)" }}
|
||||||
>
|
>
|
||||||
<div className="row row-gutter-0">
|
<div className="row row-gutter-0">
|
||||||
{serviceCards.map((card) => (
|
{fourLinkIcon.items.map((item, index) => {
|
||||||
<div key={card.id} className="col-md-6">
|
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-item">
|
||||||
<div className="featured-icon">
|
<div className="featured-icon">
|
||||||
<img alt={card.title} src={card.image} />
|
<SlotImage
|
||||||
|
src={item.image}
|
||||||
|
alt={title}
|
||||||
|
fallbackSrc={item.fallbackSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="featured-info">
|
<div className="featured-info">
|
||||||
<a href={card.href} title={card.title}>
|
<SlotLink href={href} title={title}>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
letterSpacing: "0px",
|
letterSpacing: "0px",
|
||||||
@@ -30,7 +47,7 @@ export default function ServicesSection() {
|
|||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -41,13 +58,14 @@ export default function ServicesSection() {
|
|||||||
fontSize: "10px",
|
fontSize: "10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.subtitle}
|
{kicker}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</SlotLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="featured-discount-item">
|
<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