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:
Alireza Hassani
2026-09-01 11:22:02 +03:30
co-authored by Cursor
parent 424e339986
commit 7e8a1ef7dc
33 changed files with 1708 additions and 150 deletions
+69
View File
@@ -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
+7
View File
@@ -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;
+1
View File
@@ -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"
+96
View File
@@ -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
View File
@@ -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 (
+17
View File
@@ -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);
}
+10
View File
@@ -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);
}
+9 -7
View File
@@ -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 (
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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 />
+23 -5
View File
@@ -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>
+139
View File
@@ -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>
);
}
+2 -2
View File
@@ -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 }}
+2 -3
View File
@@ -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>
+52 -23
View File
@@ -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>
</> </>
); );
} }
+36 -14
View File
@@ -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>
+23 -5
View File
@@ -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>
+13
View File
@@ -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>;
}
+27 -9
View File
@@ -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">
+36
View File
@@ -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}
/>
);
}
+34
View File
@@ -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>
);
}
+16
View File
@@ -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";
+22
View File
@@ -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(/\/$/, "");
+42
View File
@@ -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);
}
+55
View File
@@ -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) : "";
}
+44
View File
@@ -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],
},
};
}
+188
View File
@@ -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 || "کاربر";
}
+123
View File
@@ -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;
}
+20
View File
@@ -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
}
]
}
+258
View File
@@ -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 [];
}
}