Initial commit of the Dr Asadian Next.js site.

Persian RTL clinic site with Meshkee blog, auth, sitemap config, and on-page SEO.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 09:14:52 +03:30
co-authored by Cursor
commit 7c4b59c6f8
84 changed files with 10342 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.verify/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
+17
View File
@@ -0,0 +1,17 @@
Clone this website drasadian.com
Download and use banners,images and fonts from main website, icon and color Pallete from main website
Logo from main website either
Pick data from current website - all data is valid
Lang:fa
Directions: rtl
Use next js
Color palette like website
Clone only homepage, about us, contact us
we will read blog from backend later
Footer Botton row (meshkee link) should be like https://safeteb.com/
update context md for better use in other Ai and systems - update it on every prompt (If required)
+168
View File
@@ -0,0 +1,168 @@
# Dr Asadian — AI / Project Context
Persian RTL rebuild of **drasadian.com** (مطب پوست، مو و زیبایی دکتر محمد اسدیان، قم). Keep this file current — other AI tools and later prompts read it first.
## Source of truth
- Live site being cloned: https://www.drasadian.com/
- Prompt: Meshkee `basic-clone` then `backend` (`/api/v1/public/ai-prompts/dr-asadian/...`)
- Backend / Meshkee Website API: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- Apex domain: `drasadian.com` (no `www` / `api` / `customer` / `business`)
- Tenant: `GET /tenants/drasadian.com` → `businessId` `31`
- Enabled modules: `products`, `blog`, `finance`
## Stack
- Next.js 15 (App Router) + React 19 + TypeScript
- Tailwind CSS v4 (`@import "tailwindcss"` in `src/app/globals.css`) + custom CSS
- Language: `fa`, direction: `rtl`
- Fonts (downloaded from Meshkee CDN into `public/fonts/`):
- `kalameh` (Persian UI: thin / regular / bold / black)
- `a-glitch-in-time` (English kickers)
## Color palette (from live yoga3 theme CSS)
- Primary / headings / body: `#17543E`
- Theme gold (links, buttons, accents): `#D19C1D` (hover `#E9B948`)
- Mint button: `#D5E8E0` on `#17543E` text
- Footer: `#195C46` + `public/images/footer/footer-bg.jpg`
- Page gray: `#F2F2F2`
- Cards: `#F8F8F8`
- Doctor band overlay: `#1F6C54`
- Muted: `#A8A8A8`
## Routes
| Route | Purpose |
|-------|---------|
| `/` | Homepage |
| `/services` | خدمات ما (original `/services`) — hair / skin / other |
| `/about` | درباره ما (original `/aboutus`) |
| `/contact` | تماس با ما (original `/contactus`) |
| `/gallery` | گالری تصاویر مطب (original `/gallery`) |
| `/blog` | مقالات — Meshkee `GET /tenants/{domain}/blogs` |
| `/blog/{id}/{titleSlug}` | مقاله — `GET /tenants/{domain}/blogs/by-id/{id}` |
| `/blog/{id}` | Redirects to canonical `{id}/{titleSlug}` |
Original also had `/hair`, `/skin`, `/otherservices` — not cloned as separate pages. Category cards on `/services` use in-page anchors (`#hair`, `#skin`, `#other`). Header: خدمات → `/services`, گالری → `/gallery`, مقالات → `/blog`.
## Homepage order
1. Hero slider (2 banners)
2. Visible H1 intro (clinic name + description)
3. Specialized service icons
4. Latest blogs (8 from Meshkee). If the API has no posts yet, the original three cloned teasers are shown and link to `/blog`
5. Testimonials (microneedling / microderm / e-light)
6. Skin repair + about CTA band
## Meshkee auth (customer portal)
- Logged-out user icon → `https://customer.drasadian.com` (`customerPortalUrl` in `src/lib/config.ts`)
- Logged-in: `{firstName lastName} عزیز` with dropdown:
- پروفایل من → `https://customer.drasadian.com/profile`
- خروج (clears shared tokens)
- مدیریت وبسایت (admins/owners only) → `https://business.drasadian.com` via `POST /api/auth/handoff`
- Session: `src/lib/session.tsx` + cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` (shared with customer portal on `.drasadian.com`)
- Current user: website `GET /api/auth/me` → Meshkee `GET /auth/me`
- Header: `src/components/AccountMenu.tsx`
## Blog (Meshkee)
- List: `GET /tenants/drasadian.com/blogs?page=&pageSize=`
- Detail: `GET /tenants/drasadian.com/blogs/by-id/{id}` (preferred). Slug route exists but is not used for public URLs.
- Helpers: `src/lib/blog.ts` (`blogHref`, `blogImage`, `fetchBlogs`, `fetchBlogById`)
- Canonical URL: `/blog/{id}/{titleSlug}` — Farsi title slug (spaces → `-`, keep Persian letters)
- Homepage fetches `pageSize=8`
- Listing paginates 12 per page
- Do not invent blog copy. Empty API → empty listing + homepage fallback teasers only
- Blog CMS HTML is rendered as `mainTextHtml` (rich text)
## Motion
- Header fades down on load
- Sections use `Reveal` (`src/components/Reveal.tsx`) — Intersection Observer, one-shot
- Children with `.stagger-child` fade up in sequence (`--i` delay, 130ms steps)
- Slider slides crossfade (1.15s)
- `prefers-reduced-motion: reduce` disables the above
## Inner page title
- `PageHero` sits in `.page-hero-row` with a repeating minimal botanical-dot pattern (`public/images/patterns/title-pattern.svg`)
## Original business data (do not invent replacements)
- Phone: `025-32907661`
- Hours (contact): شنبه تا پنجشنبه، ۱۷ الی ۲۱
- Address: قم، میدان سپاه (جمهوری)، خیابان شهید اخلاقی، جنب مسجد امام حسن مجتبی، مطب دکترمحمد اسدیان
- Login icon → `https://customer.drasadian.com`
- Business dashboard → `https://business.drasadian.com`
- Instagram/Telegram URLs on the live site pointed to `/` — no public handles cloned
- Map coordinates were empty in site settings; contact page uses a Google Maps embed of the address
## Content & assets
- Copy, hours table, testimonials, homepage teaser fallbacks: `src/data/site.ts`
- Logo: `public/images/logo.png` (live theme `PMlhUjbrcj.png`)
- Favicon: `public/favicon.png` (from logo; live favicon was empty)
- Banners / icons / photos / gallery: `public/images/`
- Fonts: `public/fonts/`
- Remote CMS images: `meshkee-storage…`, `cdn.meshkee.com`, `c804387.parspack.net` (see `next.config.ts`)
## Footer Meshkee credit
- Bottom row matches https://safeteb.com/ (site name + framed hover credit)
- Component: `src/components/Footer.tsx` → `.meshkee-credit`
- Styles: `src/app/globals.css`
- Links to `https://meshkee.com`
- Logo: `public/images/footer/MeshkeeLogo-White.png`
## Important paths
- Layout / shell: `src/app/layout.tsx` (wraps `Providers` + session)
- Header: `src/components/Header.tsx` (split logo, RTL)
- Account menu: `src/components/AccountMenu.tsx`
- Footer: `src/components/Footer.tsx`
- Home: `src/app/page.tsx`, `src/components/HeroSlider.tsx`
- Blog: `src/app/blog/page.tsx`, `src/app/blog/[id]/[slug]/page.tsx`
- Meshkee client: `src/lib/meshkee.ts`, `src/lib/meshkee-server.ts`, `src/lib/config.ts`
- Data: `src/data/site.ts`
## Env
```bash
NEXT_PUBLIC_WEBSITE_DOMAIN=drasadian.com
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
# Optional overrides:
# NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://customer.drasadian.com
# NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.drasadian.com
```
## Commands
```bash
npm install
npm run dev
npm run build
npm run generate:sitemap-config
```
## Sitemap + robots
- **Do not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API)
- Live sitemap is an index: `/sitemap.xml` → `/sitemap-main.xml` + `/sitemap-products.xml`
- Static pages config: `GET /meshkee/sitemap-config.json` (generated at build from app routes)
- Generator: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json`
- Excludes `api`, `meshkee`, `admin`, `auth`, `login`, `checkout`, `cart`, `account`
- Blog / product / portfolio details are **not** listed in sitemap-config (Meshkee XML covers CMS URLs)
- After deploy: business dashboard → Website → Settings → Sync sitemap config (needed for static pages)
## Canonical URL shapes (Meshkee defaults — do not add `templates` unless customized)
- Blog: `/blog/{id}/{titleSlug}` → `GET /tenants/{domain}/blogs/by-id/{id}`
- Product: `/products/{id}/{nameFaSlug}` → `GET /tenants/{domain}/products/by-id/{id}` (not built yet; catalog empty)
- Category: `/products/category/{id}/{nameFaSlug}` → `GET /tenants/{domain}/categories/by-id/{id}`
- Portfolio: `/portfolios/{id}/{titleFaSlug}` → `GET /tenants/{domain}/portfolios/by-id/{id}` (not built yet)
- Slugify (`src/lib/seo-slug.ts`): keep Farsi letters, spaces → `-`, strip other punctuation. Do not use legacy DB slug in public URLs.
## On-page SEO
- Helper: `src/lib/seo.ts` (`pageMetadata`, unique absolute `<title>`, clipped meta description ~158 chars, canonical, OG)
- Inner-page titles append `| دکتر محمد اسدیان`. Homepage title is unique and does not use the Next.js default
- Exactly one visible H1 per page (never `.sr-only` / CSS-hidden). Homepage H1 is `.home-intro`
- Section headings default to `h2` (`SectionHeading`). Service cards / article cards use `h3` under a section `h2`
- Prefer CMS `title` / `abstract` / image for blog metadata. Fallback teasers only when the API is empty
- Content images have meaningful `alt` (CMS title when available). Decorative `deco.png` uses `alt=""`
- `src/app/not-found.tsx`: unique title + one H1, `noindex`
- Favicon: `public/favicon.png` (tenant favicon API currently returns null)
## Gallery
- 12 photos from the live `/gallery` page in `public/images/gallery/01.jpg`–`12.jpg`
- Lightbox: `src/components/GalleryGrid.tsx` (click to enlarge, arrows / Escape)
- Header گالری → `/gallery`
## Next planned prompts
1. Products / portfolio pages if the clinic starts publishing through Meshkee
2. Image slots if a later prompt provides them
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;
+26
View File
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "cdn.meshkee.com",
},
{
protocol: "https",
hostname: "c804387.parspack.net",
},
],
},
};
export default nextConfig;
+6263
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "dr-asadian",
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.21",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
<g fill="#17543e" fill-opacity="0.16">
<circle cx="6" cy="8" r="1.2"/>
<circle cx="50" cy="12" r="0.9"/>
<path d="M20.5 40c2.2-3.4 2.1-6.6 0-9.4-2.1 2.8-2.2 6 0 9.4z"/>
</g>
<circle cx="34" cy="30" r="1.05" fill="#d19c1d" fill-opacity="0.18"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+99
View File
@@ -0,0 +1,99 @@
/**
* 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 },
"/services": { changefreq: "weekly", priority: 0.8 },
"/gallery": { changefreq: "monthly", priority: 0.7 },
"/blog": { changefreq: "weekly", priority: 0.7 },
"/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_MESHKEE_DOMAIN ||
"drasadian.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();
+121
View File
@@ -0,0 +1,121 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import SectionHeading from "@/components/SectionHeading";
import { aboutSchedule, doctorBio, testimonials } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "درباره ما",
description:
"درباره مطب پوست، مو و زیبایی دکتر محمد اسدیان در قم و رویکرد درمانی کلینیک برای خدمات پوست و مو.",
path: "/about",
});
function StarIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2.5l2.7 6.4 6.8.6-5.2 4.5 1.6 6.6L12 16.9 6.1 20.6l1.6-6.6L2.5 9.5l6.8-.6L12 2.5z" />
</svg>
);
}
function InstagramIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.6" />
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.6" />
<circle cx="17.2" cy="6.8" r="1" fill="currentColor" />
</svg>
);
}
export default function AboutPage() {
return (
<>
<Reveal className="reveal-solo">
<PageHero title="دربــاره ما بیشتــر بدانیــد" kicker="About Us" />
</Reveal>
<Reveal as="section" className="container about-hours">
<div className="hours-copy stagger-child" style={{ "--i": 0 } as CSSProperties}>
<h2>{aboutSchedule.heading}</h2>
<span className="section-kicker">{aboutSchedule.kicker}</span>
<p>{aboutSchedule.intro}</p>
<Link href="/contact" className="btn btn-dark">
تماس بگیرید...
</Link>
</div>
<div className="hours-table-wrap yoga-shadow stagger-child" style={{ "--i": 1 } as CSSProperties}>
<h3>{aboutSchedule.yearLabel}</h3>
<table className="hours-table">
<tbody>
<tr>
{aboutSchedule.headers.map((cell) => (
<td key={cell}>{cell}</td>
))}
</tr>
{aboutSchedule.rows.map((row) => (
<tr key={row[0]}>
{row.map((cell, i) => (
<td key={`${row[0]}-${i}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Reveal>
<Reveal as="section" className="doctor-band">
<div className="container">
<div className="doctor-card stagger-child" style={{ "--i": 0 } as CSSProperties}>
<h2>{doctorBio.heading}</h2>
<span className="section-kicker">{doctorBio.kicker}</span>
{doctorBio.paragraphs.map((p) => (
<p key={p.slice(0, 24)}>{p}</p>
))}
<span className="ig-icon">
<InstagramIcon />
</span>
</div>
</div>
</Reveal>
<Reveal as="section" className="section">
<div className="container">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="خدمــات تخصصــی مطــب" kicker="Specialized Services" />
</div>
<div className="testimonials-grid">
{testimonials.map((item, i) => (
<div
key={item.title}
className="stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<Image
className="testimonial-photo"
src={item.src}
alt={`نمونه کار ${item.title} در مطب دکتر اسدیان`}
width={101}
height={101}
/>
<div className="testimonial-quote">
<p>{item.body}</p>
<div className="testimonial-meta">
<StarIcon />
<span>{item.title}</span>
</div>
</div>
</div>
))}
</div>
</div>
</Reveal>
</>
);
}
+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);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { site } from "@/data/site";
import { blogExcerpt, blogHref, blogImage, blogTitleSlug, fetchBlogById } from "@/lib/blog";
import { formatFaDate } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string; slug: string }>;
}): Promise<Metadata> {
const { id } = await params;
const post = await fetchBlogById(id);
if (!post) {
return {
...pageMetadata({
title: "مقاله یافت نشد",
description: `مقاله مورد نظر در ${site.fullName} پیدا نشد.`,
path: `/blog/${id}`,
}),
robots: { index: false, follow: false },
};
}
return pageMetadata({
title: post.title,
description: post.abstract || blogExcerpt(post) || post.title,
path: blogHref(post),
image: blogImage(post),
});
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ id: string; slug: string }>;
}) {
const { id, slug } = await params;
const post = await fetchBlogById(id);
if (!post) notFound();
const canonical = blogTitleSlug(post.title);
if (decodeURIComponent(slug) !== canonical) {
redirect(blogHref(post));
}
return (
<>
<Reveal className="reveal-solo">
<PageHero title={post.title} kicker="Article" />
</Reveal>
<Reveal as="section" className="section blog-detail">
<div className="container detail-page">
{post.publishedAt || post.createdAt ? (
<p className="meta-line">{formatFaDate(post.publishedAt || post.createdAt)}</p>
) : null}
{post.abstract ? <p className="detail-lead">{post.abstract}</p> : null}
{post.mainTextHtml ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: post.mainTextHtml }} />
) : null}
<Link href="/blog" className="back-link">
بازگشت به مقالات
</Link>
</div>
</Reveal>
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { notFound, redirect } from "next/navigation";
import { blogHref, fetchBlogById, fetchBlogBySlug } from "@/lib/blog";
export const revalidate = 60;
/** Legacy `/blog/{slug-or-id}` → canonical `/blog/{id}/{titleSlug}`. */
export default async function LegacyBlogRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const byId = await fetchBlogById(id);
if (byId) redirect(blogHref(byId));
const bySlug = await fetchBlogBySlug(id);
if (bySlug) redirect(blogHref(bySlug));
notFound();
}
+87
View File
@@ -0,0 +1,87 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { blogExcerpt, blogHref, blogImage, fetchBlogs } from "@/lib/blog";
import { faDigits, formatFaDate } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export const metadata = pageMetadata({
title: "مقالات",
description: "مقالات آموزشی پوست، مو و زیبایی از مطب دکتر محمد اسدیان در قم.",
path: "/blog",
image: "/images/articles/article-1.jpg",
});
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const result = await fetchBlogs({ page, pageSize: 12 });
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
return (
<>
<Reveal className="reveal-solo">
<PageHero title="مقــالات مطــب" kicker="Blog" />
</Reveal>
<Reveal as="section" className="section blog-page">
<div className="container">
{result.items.length === 0 ? (
<p className="empty-note">هنوز مقاله‌ای در سایت منتشر نشده است.</p>
) : (
<div className="articles-grid">
{result.items.map((post, i) => (
<Link
key={post.id}
href={blogHref(post)}
className="article-card article-card--link stagger-child"
style={{ "--i": i } as CSSProperties}
>
<Image src={blogImage(post)} alt={post.title} width={780} height={520} />
<div className="body">
<h2>{post.title}</h2>
{post.publishedAt || post.createdAt ? (
<span className="kicker">{formatFaDate(post.publishedAt || post.createdAt)}</span>
) : (
<span className="kicker">Article</span>
)}
{blogExcerpt(post) ? <p>{blogExcerpt(post)}</p> : null}
<span className="more" aria-hidden>
‹
</span>
</div>
</Link>
))}
</div>
)}
{result.items.length > 0 && pageCount > 1 ? (
<div className="pagination">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
return (
<Link
key={number}
href={number === 1 ? "/blog" : `/blog?page=${number}`}
className={number === page ? "active" : undefined}
>
{faDigits(number)}
</Link>
);
})}
</div>
) : null}
</div>
</Reveal>
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { CSSProperties } from "react";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "تماس با ما",
description: `آدرس و تلفن مطب دکتر محمد اسدیان در قم؛ نوبت‌دهی ${site.hoursShort}.`,
path: "/contact",
});
function InstagramIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.6" />
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.6" />
<circle cx="17.2" cy="6.8" r="1" fill="currentColor" />
</svg>
);
}
export default function ContactPage() {
return (
<>
<Reveal className="reveal-solo">
<PageHero title="با مــا در ارتبــاط باشیــد" kicker="Contact Us" />
</Reveal>
<Reveal as="section" className="container contact-grid">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<h2>ساعـات کار مطـب</h2>
<p>{site.hoursNote}</p>
<div className="hours-chip">
<span>شنبه تا پنجشنبه</span>
<span>17 الی 21</span>
</div>
</div>
<div className="stagger-child" style={{ "--i": 1 } as CSSProperties}>
<h2>با ما همــراه باشیــد...</h2>
<p>در پیج اینستاگرام و تلگرام ما را همراهی کنید تا با نمونه کارهای ما بیشتر آشنا شوید.</p>
<span className="ig-icon">
<InstagramIcon />
</span>
</div>
<div className="stagger-child" style={{ "--i": 2 } as CSSProperties}>
<h2>آدرس و شمــاره تمــاس مطـب</h2>
<p>آدرس : {site.address}</p>
<a className="phone-lg" href={`tel:${site.phoneTel}`} dir="ltr">
{site.phoneDisplay}
</a>
</div>
</Reveal>
<Reveal className="map-wrap reveal-solo">
<iframe title="موقعیت مطب دکتر اسدیان روی نقشه" src={site.mapEmbed} loading="lazy" />
</Reveal>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import GalleryGrid from "@/components/GalleryGrid";
import { galleryImages } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "گالری تصاویر مطب",
description: "گالری تصاویر و نمونه کارهای مطب پوست، مو و زیبایی دکتر محمد اسدیان در قم.",
path: "/gallery",
image: "/images/gallery/01.jpg",
});
export default function GalleryPage() {
return (
<>
<Reveal className="reveal-solo">
<PageHero title="گالـری تصـاویرمطــب" kicker="Gallery" />
</Reveal>
<Reveal as="section" className="section gallery-page">
<div className="container">
<GalleryGrid items={galleryImages} />
</div>
</Reveal>
</>
);
}
+1527
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import Providers from "@/components/Providers";
import { site } from "@/data/site";
import { websiteDomain } from "@/lib/config";
import { getBusinessId } from "@/lib/meshkee";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL(`https://${websiteDomain}`),
title: {
default: `${site.doctorName} | مطب پوست، مو و زیبایی در قم`,
template: `%s | ${site.doctorName}`,
},
description: site.description,
icons: {
icon: "/favicon.png",
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const businessId = await getBusinessId();
return (
<html lang="fa" dir="rtl">
<body>
<Providers businessId={businessId}>
<Header />
<main>{children}</main>
<Footer />
</Providers>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { site } from "@/data/site";
export const metadata: Metadata = {
title: { absolute: `صفحه پیدا نشد | ${site.doctorName}` },
description: `صفحه مورد نظر در ${site.fullName} پیدا نشد. به صفحه اصلی بازگردید.`,
robots: { index: false, follow: false },
};
export default function NotFound() {
return (
<>
<Reveal className="reveal-solo">
<PageHero title="صفحـه پیـدا نشـد" kicker="Not Found" />
</Reveal>
<Reveal as="section" className="section">
<div className="container not-found">
<p>این صفحه در {site.fullName} وجود ندارد یا جابه‌جا شده است.</p>
<Link href="/" className="btn btn-dark">
بازگشت به صفحه اصلی
</Link>
</div>
</Reveal>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import HeroSlider from "@/components/HeroSlider";
import Reveal from "@/components/Reveal";
import SectionHeading from "@/components/SectionHeading";
import { articles, serviceIcons, site, testimonials } from "@/data/site";
import { blogExcerpt, blogHref, blogImage, fetchBlogs } from "@/lib/blog";
import { formatFaDate } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export const metadata = pageMetadata({
title: `${site.doctorName} | مطب پوست، مو و زیبایی در قم`,
description: site.description,
path: "/",
image: "/images/banners/banner-1.jpg",
});
function StarIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2.5l2.7 6.4 6.8.6-5.2 4.5 1.6 6.6L12 16.9 6.1 20.6l1.6-6.6L2.5 9.5l6.8-.6L12 2.5z" />
</svg>
);
}
export default async function HomePage() {
const latest = await fetchBlogs({ page: 1, pageSize: 8 });
const posts = latest.items.slice(0, 8);
return (
<>
<Reveal className="reveal-solo" delay={40}>
<HeroSlider />
</Reveal>
<Reveal className="home-intro reveal-solo">
<div className="container">
<h1>{site.fullName}</h1>
<p>{site.description}</p>
</div>
</Reveal>
<Reveal as="section" className="section" id="services" delay={80}>
<div className="container">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="خدمــات تخصصــی مطــب" kicker="Specialized Services" />
</div>
<div className="service-icons">
{serviceIcons.map((item, i) => (
<Link
key={item.src}
href={item.href}
className="service-icon stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<Image src={item.src} alt={item.alt} width={150} height={150} />
</Link>
))}
</div>
</div>
</Reveal>
<Reveal as="section" className="section-tight" id="articles">
<div className="container">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="دربـاره سلامــت پوســت و مو بیشتــر بدانید" kicker="Article" />
</div>
{posts.length > 0 ? (
<>
<div className="articles-grid">
{posts.map((post, i) => (
<Link
key={post.id}
href={blogHref(post)}
className="article-card article-card--link stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<Image src={blogImage(post)} alt={post.title} width={780} height={520} />
<div className="body">
<h3>{post.title}</h3>
<span className="kicker">
{formatFaDate(post.publishedAt || post.createdAt) || "Article"}
</span>
{blogExcerpt(post) ? <p>{blogExcerpt(post)}</p> : null}
<span className="more" aria-hidden>
‹
</span>
</div>
</Link>
))}
</div>
<div className="section-cta stagger-child" style={{ "--i": posts.length + 1 } as CSSProperties}>
<Link href="/blog" className="btn btn-dark">
مشاهده همه مقالات
</Link>
</div>
</>
) : (
<div className="articles-grid">
{articles.map((article, i) => (
<Link
key={article.title}
href="/blog"
className="article-card article-card--link stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<Image src={article.src} alt={article.title} width={780} height={780} />
<div className="body">
<h3>{article.title}</h3>
<span className="kicker">{article.kicker}</span>
<p>{article.body}</p>
<span className="more" aria-hidden>
‹
</span>
</div>
</Link>
))}
</div>
)}
</div>
</Reveal>
<Reveal as="section" className="section">
<div className="container">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="خدمــات تخصصــی مطــب" kicker="Specialized Services" />
</div>
<div className="testimonials-grid">
{testimonials.map((item, i) => (
<div
key={item.title}
className="stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<Image
className="testimonial-photo"
src={item.src}
alt={`نمونه کار ${item.title} در مطب دکتر اسدیان`}
width={101}
height={101}
/>
<div className="testimonial-quote">
<p>{item.body}</p>
<div className="testimonial-meta">
<StarIcon />
<span>{item.title}</span>
</div>
</div>
</div>
))}
</div>
</div>
</Reveal>
<Reveal as="section" className="section-tight">
<div className="container two-col">
<div className="skin-copy stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="ترمیــم پوســت" kicker="Skin Repair" />
<p>
زمانیکه پوست آسیب می‌بیند، از طریق ترمیم بافت آسیب دیده و جایگزین ساختن بافت از دست رفته با
سلولهای جدید، پاسخ می‌دهد. طی روند ترمیم، بافت مرده یا آسیب دیده، ابتدا توسط بافت اسکار و به
تدریج با سلولهای جدید سالم جایگزین می‌شود.
</p>
<Link href="/about" className="btn btn-dark">
درباره ما
</Link>
</div>
<div className="yoga-shadow stagger-child" style={{ "--i": 1 } as CSSProperties}>
<Image
src="/images/about/skin-repair.jpg"
alt="ترمیم پوست در مطب دکتر محمد اسدیان"
width={780}
height={780}
/>
</div>
</div>
</Reveal>
<Reveal as="section" className="about-cta">
<div className="container">
<Image
className="deco stagger-child"
style={{ "--i": 0 } as CSSProperties}
src="/images/about/deco.png"
alt=""
width={274}
height={293}
/>
<h2 className="stagger-child" style={{ "--i": 1 } as CSSProperties}>
با مــراجعه به مطـب دکــتر محمد اســدیان از کلیـه
<br />
خدمــات زیبایی با قیمــتی مناســب بهره منــد شویـد.
</h2>
<span className="section-kicker stagger-child" style={{ "--i": 2 } as CSSProperties}>
About Us
</span>
<p className="lead stagger-child" style={{ "--i": 3 } as CSSProperties}>
نـخـستین گـام برای زیبایی پوست صورت ، آگاهی یافتن ازنوع پوست میباشد.
<br />
زیرا ازاین طریق شما قادر خواهید بودهر چه بهتر از پوستتان مراقبت و هـمـچنـیـن فـراورده های
بـهـداشـتـی و آرایشی مناسب نوع پوست خود را انتخاب کنید.
<br />
<br />
مطب دکتر محمد اسدیان ارائه دهنده انواع خدمات زیبایی و پوست و مو همگام با تکنوژوی روز دنیا.
</p>
<div className="cta-row stagger-child" style={{ "--i": 4 } as CSSProperties}>
<Link href="/about" className="btn btn-dark">
مشاهده اطلاعات بیشتر
</Link>
<Link href="/contact" className="btn btn-mint">
با ما در تماس باشید
</Link>
</div>
</div>
</Reveal>
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import SectionHeading from "@/components/SectionHeading";
import { serviceCategories } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "خدمات مطب",
description:
"خدمات تخصصی پوست، مو و سایر درمان‌های مطب دکتر محمد اسدیان در قم؛ از کاشت مو و میکرونیدلینگ تا لیزر و رژیم درمانی.",
path: "/services",
image: "/images/services/skin.jpg",
});
export default function ServicesPage() {
return (
<>
<Reveal className="reveal-solo">
<PageHero title="خدمـات اصـلی مطــب" kicker="Services" />
</Reveal>
<Reveal as="section" className="section services-page">
<div className="container">
<div className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
<SectionHeading title="زیبایــی خــود را با ما کامــل کنید..." kicker="Complete Your Beauty" />
</div>
<div className="services-grid">
{serviceCategories.map((category, i) => (
<article
key={category.id}
id={category.id}
className="service-card stagger-child"
style={{ "--i": i + 1 } as CSSProperties}
>
<div className="service-card-media">
<Image
src={category.image}
alt={`${category.title} در مطب دکتر محمد اسدیان`}
width={780}
height={780}
/>
</div>
<div className="service-card-body">
<h3>{category.title}</h3>
<span className="kicker">{category.kicker}</span>
<ul>
{category.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<div className="service-card-foot">
<span>{category.items.length} خدمت</span>
<Link href="/contact">رزرو نوبت</Link>
</div>
</div>
</article>
))}
</div>
</div>
</Reveal>
<Reveal as="section" className="about-cta">
<div className="container">
<h2 className="stagger-child" style={{ "--i": 0 } as CSSProperties}>
با مــراجعه به مطـب دکــتر محمد اســدیان از کلیـه
<br />
خدمــات زیبایی با قیمــتی مناســب بهره منــد شویـد.
</h2>
<span className="section-kicker stagger-child" style={{ "--i": 1 } as CSSProperties}>
About Us
</span>
<div className="cta-row stagger-child" style={{ "--i": 2 } as CSSProperties}>
<Link href="/about" className="btn btn-dark">
مشاهده اطلاعات بیشتر
</Link>
<Link href="/contact" className="btn btn-mint">
با ما در تماس باشید
</Link>
</div>
</div>
</Reveal>
</>
);
}
+149
View File
@@ -0,0 +1,149 @@
"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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.6" />
<path
d="M5.5 19.2c.8-3.1 3.4-5.2 6.5-5.2s5.7 2.1 6.5 5.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
aria-hidden
className={open ? "account-menu__chevron open" : "account-menu__chevron"}
>
<path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
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" 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"
>
<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>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Image from "next/image";
import Link from "next/link";
import { site } from "@/data/site";
export default function Footer() {
return (
<footer className="site-footer">
<div className="container footer-main">
<Link href="/">
<Image src="/images/logo.png" alt="لوگوی مطب پوست، مو و زیبایی دکتر اسدیان" width={125} height={120} />
</Link>
<strong>{site.fullName}</strong>
</div>
<div className="footer-bottom">
<div className="container">
<div className="footer-bottom-bar">
<div className="footer-bottom-site">{site.name}</div>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<Image
src="/images/footer/MeshkeeLogo-White.png"
alt="Meshkee"
className="meshkee-credit__logo"
width={42}
height={42}
/>
<span className="meshkee-credit__text">
<span className="meshkee-credit__label">Designed And Developed By</span>
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
</span>
</a>
</div>
</div>
</div>
</footer>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useId, useState, type CSSProperties } from "react";
export type GalleryItem = {
src: string;
alt: string;
};
function SearchIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="11" cy="11" r="6.25" stroke="currentColor" strokeWidth="1.8" />
<path d="M16 16.5 20 20.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function Chevron({ dir }: { dir: "next" | "prev" }) {
const points = dir === "next" ? "14 6 8 12 14 18" : "10 6 16 12 10 18";
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
<polyline points={points} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export default function GalleryGrid({ items }: { items: GalleryItem[] }) {
const [index, setIndex] = useState<number | null>(null);
const titleId = useId();
const open = index !== null;
const current = open ? items[index] : null;
const close = useCallback(() => setIndex(null), []);
const goPrev = useCallback(() => {
setIndex((i) => (i === null ? i : (i + items.length - 1) % items.length));
}, [items.length]);
const goNext = useCallback(() => {
setIndex((i) => (i === null ? i : (i + 1) % items.length));
}, [items.length]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
if (e.key === "ArrowRight") goPrev();
if (e.key === "ArrowLeft") goNext();
};
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, close, goPrev, goNext]);
return (
<>
<div className="gallery-grid">
{items.map((item, i) => (
<button
key={item.src}
type="button"
className="gallery-item stagger-child"
style={{ "--i": i } as CSSProperties}
onClick={() => setIndex(i)}
aria-label={`نمایش تصویر ${i + 1} از ${items.length}`}
>
<Image src={item.src} alt={item.alt} width={1000} height={750} />
<span className="gallery-mask">
<span className="gallery-zoom">
<SearchIcon />
</span>
</span>
</button>
))}
</div>
{open && current ? (
<div className="lightbox" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<button type="button" className="lightbox-backdrop" aria-label="بستن" onClick={close} />
<div className="lightbox-frame">
<p id={titleId} className="sr-only">
{current.alt}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.src} alt={current.alt} />
<div className="lightbox-toolbar">
<button type="button" onClick={goNext} aria-label="تصویر بعد">
<Chevron dir="next" />
</button>
<span dir="ltr">
{index + 1} / {items.length}
</span>
<button type="button" onClick={goPrev} aria-label="تصویر قبل">
<Chevron dir="prev" />
</button>
</div>
<button type="button" className="lightbox-close" onClick={close} aria-label="بستن">
×
</button>
</div>
</div>
) : null}
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import { navLeft, navRight, site } from "@/data/site";
export default function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<header className="site-header">
<div className="container header-inner">
<button
type="button"
className="nav-toggle"
aria-label="منو"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
☰
</button>
<nav className={`main-nav main-nav--start${open ? " open" : ""}`}>
{navLeft.map((link) => {
const active =
link.href === "/"
? pathname === "/"
: pathname === link.href || pathname.startsWith(`${link.href}/`);
return (
<Link
key={link.href}
href={link.href}
className={active ? "active" : undefined}
onClick={() => setOpen(false)}
>
{link.label}
</Link>
);
})}
</nav>
<Link href="/" className="logo-link" aria-label={site.name}>
<Image src="/images/logo.png" alt="لوگوی مطب پوست، مو و زیبایی دکتر اسدیان" width={125} height={120} priority />
</Link>
<nav className={`main-nav main-nav--end${open ? " open" : ""}`}>
{navRight.map((link) => (
<Link
key={link.href}
href={link.href}
className={pathname.startsWith(link.href) ? "active" : undefined}
onClick={() => setOpen(false)}
>
{link.label}
</Link>
))}
<AccountMenu />
</nav>
</div>
</header>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { banners } from "@/data/site";
export default function HeroSlider() {
const [index, setIndex] = useState(0);
const go = useCallback((delta: number) => {
setIndex((current) => (current + delta + banners.length) % banners.length);
}, []);
useEffect(() => {
const id = window.setInterval(() => go(1), 6000);
return () => window.clearInterval(id);
}, [go]);
return (
<section className="hero-slider" aria-label="اسلایدر">
{banners.map((banner, i) => (
<div key={banner.src} className={`hero-slide${i === index ? " active" : ""}`}>
<Image
src={banner.src}
alt={banner.alt}
width={1903}
height={768}
priority={i === 0}
/>
</div>
))}
<button type="button" className="hero-nav prev" aria-label="قبلی" onClick={() => go(-1)}>
‹
</button>
<button type="button" className="hero-nav next" aria-label="بعدی" onClick={() => go(1)}>
›
</button>
<div className="hero-dots">
{banners.map((banner, i) => (
<button
key={banner.src}
type="button"
className={i === index ? "active" : undefined}
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
/>
))}
</div>
</section>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function PageHero({ title, kicker }: { title: string; kicker: string }) {
return (
<section className="page-hero">
<div className="page-hero-row">
<div className="container">
<h1>{title}</h1>
<span className="section-kicker">{kicker}</span>
</div>
</div>
</section>
);
}
+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>;
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ElementType, type ReactNode } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
as?: "div" | "section";
id?: string;
delay?: number;
};
export default function Reveal({
children,
className = "",
as: Tag = "div",
id,
delay = 0,
}: RevealProps) {
const ref = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.14, rootMargin: "0px 0px -6% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const Component = Tag as ElementType;
return (
<Component
ref={ref}
id={id}
className={`reveal${visible ? " is-in" : ""}${className ? ` ${className}` : ""}`}
style={{ "--d": `${delay}ms` } as CSSProperties}
>
{children}
</Component>
);
}
+16
View File
@@ -0,0 +1,16 @@
export default function SectionHeading({
title,
kicker,
as: Tag = "h2",
}: {
title: string;
kicker: string;
as?: "h1" | "h2" | "h3";
}) {
return (
<div className="section-heading">
<Tag>{title}</Tag>
<span className="section-kicker">{kicker}</span>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
export const site = {
name: "مطب دکتر اسدیان",
fullName: "مطب پوست، مو و زیبایی دکتر اسدیان",
doctorName: "دکتر محمد اسدیان",
description:
"مطب دکتر محمد اسدیان ارائه‌دهنده انواع خدمات زیبایی و پوست و مو همگام با تکنولوژی روز دنیا در قم.",
phoneDisplay: "025-32907661",
phoneTel: "02532907661",
address:
"قم، میدان سپاه (جمهوری)، خیابان شهید اخلاقی، جنب مسجد امام حسن مجتبی، مطب دکترمحمد اسدیان",
hoursShort: "شنبه تا پنجشنبه، ۱۷ الی ۲۱",
hoursNote:
"مطب دکترمحمد اسدیان از شنبه تا پنج شنبه آماده ارائه کلیه خدمات به مراجعین محترم می‌باشد.",
loginUrl: "https://customer.drasadian.com",
mapEmbed:
"https://maps.google.com/maps?q=%D9%82%D9%85%D8%8C%20%D9%85%DB%8C%D8%AF%D8%A7%D9%86%20%D8%B3%D9%BE%D8%A7%D9%87%D8%8C%20%D8%AE%DB%8C%D8%A7%D8%A8%D8%A7%D9%86%20%D8%B4%D9%87%DB%8C%D8%AF%20%D8%A7%D8%AE%D9%84%D8%A7%D9%82%DB%8C%D8%8C%20%D8%AC%D9%86%D8%A8%20%D9%85%D8%B3%D8%AC%D8%AF%20%D8%A7%D9%85%D8%A7%D9%85%20%D8%AD%D8%B3%D9%86%20%D9%85%D8%AC%D8%AA%D8%A8%DB%8C%D8%8C%20%D9%85%D8%B7%D8%A8%20%D8%AF%DA%A9%D8%AA%D8%B1%20%D8%A7%D8%B3%D8%AF%DB%8C%D8%A7%D9%86&hl=fa&z=16&output=embed",
};
export const navLeft = [
{ href: "/", label: "صفحه اصلی" },
{ href: "/services", label: "خدمات" },
{ href: "/gallery", label: "گالری" },
{ href: "/blog", label: "مقالات" },
];
export const navRight = [
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export const banners = [
{ src: "/images/banners/banner-1.jpg", alt: "مطب پوست، مو و زیبایی دکتر محمد اسدیان در قم — تصویر معرفی ۱" },
{ src: "/images/banners/banner-2.jpg", alt: "مطب پوست، مو و زیبایی دکتر محمد اسدیان در قم — تصویر معرفی ۲" },
];
export const serviceIcons = [
{ src: "/images/services/service-1.png", alt: "خدمات تخصصی پوست مطب دکتر اسدیان", href: "/services#skin" },
{ src: "/images/services/service-2.png", alt: "خدمات تخصصی مو مطب دکتر اسدیان", href: "/services#hair" },
{ src: "/images/services/service-3.png", alt: "سایر خدمات مطب دکتر اسدیان", href: "/services#other" },
];
export const articles = [
{
src: "/images/articles/article-1.jpg",
title: "رازهای سلامت و زیبایی مو",
kicker: "Hair",
body: "درست است که ژنتیک نقش کلیدی در داشتن موهایی زیبا را ایفا می‌کند. اما داشتن رژیم غذایی مناسب، شرایط آب و هوایی، و به طور کلی مراقبت‌های شما، برای داشتن موهایی سالم و جذاب بسیار مهم است.",
},
{
src: "/images/articles/article-2.jpg",
title: "جوانسازی پوست با ماساژ صورت",
kicker: "Massage",
body: "ماساژ بدن، پاها و ماساژ صورت، همگی بسیار آرامشبخش هستند. اما ماساژ صورت برای حفظ ظاهر زیبا نیز مفید است. وقتی صحبت از زیبایی صورت می‌شود، ماساژ منظم صورت اهمیت خاصی پیدا می‌کند",
},
{
src: "/images/articles/article-3.jpg",
title: "نقش کلاژن در سلامت پوست",
kicker: "Collagen",
body: "کلاژن یکی از فراوان‌ترین پروتئین های موجود در بدن انسان است که بخش بزرگی از پوست، مو، و ناخن ها را شکل می دهد و از این رو، جای شگفتی نیست که واژه ای مطرح و بزرگ در دنیای زیبایی محسوب شود.",
},
];
export const serviceCategories = [
{
id: "hair",
title: "خدمات تخصصی مو",
kicker: "Hair Services",
image: "/images/services/hair.jpg",
items: ["کاشت مو", "مزوتراپی سر", "میکرواسکالپ", "میکروپیگمنتیشن"],
},
{
id: "skin",
title: "خدمات تخصصی پوست",
kicker: "Dermatology Services",
image: "/images/services/skin.jpg",
items: [
"درمان بیماری‌های پوستی",
"تزریق ژل و بوتاکس",
"لیزر موهای زائد",
"لیفتینگ صورت و بدن با RF",
"میکرونیدلینگ",
"میکرودرم ابریژن",
"هیدرودرمی",
"اولتراسونیک",
"وکیوم",
],
},
{
id: "other",
title: "سایر خدمات",
kicker: "Other Services",
image: "/images/services/other.jpg",
items: ["رژیم درمانی", "لاغری موضعی", "جراحی Nail Rolling", "بیوپسی تشخیصی پوست"],
},
];
export const testimonials = [
{
src: "/images/testimonials/microneedling.png",
title: "دستگاه میکرونیدلینگ",
body: "من قبل از عید میکرونیدلینگ کردم چون پوستم خیلی جوش داشت. الان تقریبا 3 ماهی داره میگذره، هر کی پوستمو می بینه خیلی خوشش اومده و می خواد بره انجام بده.",
},
{
src: "/images/testimonials/microderm.png",
title: "دستگاه میکرودرمی",
body: "من صورتم خیلی لک داشت و البته تیرگی و جوش دار بود با چند دوره میکرودرم ابریژن صورتم عالی شد البته ضد افتاب دایم استفاده میکنم .",
},
{
src: "/images/testimonials/elight.png",
title: "دستگاه ای لایت",
body: "من پاهام به شدت واریس داشت و جلوه ی بدی پیدا کرده بود. چند ماهی هست که دکتر برای من با دستگاه ای لایت رگهای واریسیم رو درمان کرده الان راضیم و بر نگشته.",
},
];
export const aboutSchedule = {
heading: "ساعات نوبت دهی مطب",
kicker: "Turn Ratings",
intro:
"زمانیکه پوست آسیب می‌بیند، از طریق ترمیم بافت آسیب دیده و جایگزین ساختن بافت از دست رفته با سلولهای جدید، پاسخ می‌دهد. طی روند ترمیم، بافت مرده یا آسیب دیده، ابتدا توسط بافت اسکار و به تدریج با سلولهای جدید سالم جایگزین می‌شود. این درمان را از ما بخواهید...",
yearLabel: "برنامه 2019",
headers: ["ساعت کاری", "شنبه", "یکشنبه", "دوشنبه", "سه شنبه", "چهارشنبه", "پنجشنبه"],
rows: [
["صبح", "9-11", "9-11", "-", "-", "9-13", "-"],
["عصر", "1-11", "16-22", "16-22", "-", "-", "17-21"],
],
};
export const galleryImages = Array.from({ length: 12 }, (_, i) => {
const n = String(i + 1).padStart(2, "0");
return {
src: `/images/gallery/${n}.jpg`,
alt: `گالری مطب پوست، مو و زیبایی دکتر اسدیان — تصویر ${i + 1}`,
};
});
export const doctorBio = {
heading: "دکتــر اسدیــان",
kicker: "Dr.Asadian",
paragraphs: [
"دکتر معتبر از ابتدای کار حرفه ای خود همواره با ارائه و روش های درمانی و تکنیکهای جراحی زیبایی بر اساس اصول و معیارهای علم روز سعی کرده است یکی از پیشگامان در ارائه خدمات جراحی زیبایی می باشد. در حقیقت روش درمانی خود را در غالب یک برنامه هنرمندانه با هزینه مقرون به صرفه به بیماران ارائه دهد.",
"مطمئن باشید در مطب دکتر اسدیان کلیه خدمات زیبایی با شیوه های درمانی روز دنیا ارائه می گردد پس با آسودگی خاطر درمان مشکلات پوستی خود را از ما بخواهید.",
],
};
+96
View File
@@ -0,0 +1,96 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
import { stripHtml } from "@/lib/format";
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
export type BlogPost = {
id: string;
title: string;
slug: string;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
commentCount?: number;
author?: { firstName?: string; lastName?: string; name?: string } | string | null;
};
export type PagedBlogs = {
items: BlogPost[];
total: number;
page: number;
pageSize: number;
};
type RawBlog = Partial<BlogPost> & {
id?: string | number;
titleFa?: string;
};
function normalizeBlog(raw: RawBlog | null | undefined): BlogPost | null {
if (raw?.id == null) return null;
const title = raw.title || raw.titleFa || "";
if (!title) return null;
return {
id: String(raw.id),
title,
slug: raw.slug || slugifyForUrl(title, String(raw.id)),
abstract: raw.abstract ?? null,
mainTextHtml: raw.mainTextHtml ?? null,
titleImageUrl: raw.titleImageUrl ?? null,
publishedAt: raw.publishedAt ?? null,
createdAt: raw.createdAt ?? null,
commentCount: raw.commentCount,
author: raw.author ?? null,
};
}
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 12));
const data = await meshkeeGet<PagedBlogs & { blogs?: RawBlog[] }>(
tenantPath(`/blogs?${query.toString()}`),
{ items: [], total: 0, page: 1, pageSize: params.pageSize ?? 12 },
);
const rawItems = data.items?.length ? data.items : data.blogs ?? [];
const items = rawItems.map((item) => normalizeBlog(item)).filter((item): item is BlogPost => Boolean(item));
return {
items,
total: data.total || items.length,
page: data.page || params.page || 1,
pageSize: data.pageSize || params.pageSize || 12,
};
}
export async function fetchBlogById(id: string) {
const data = await meshkeeGet<{ blog?: RawBlog }>(
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
{},
);
return normalizeBlog(data.blog);
}
/** @deprecated Prefer fetchBlogById for public detail pages. */
export async function fetchBlogBySlug(slug: string) {
const data = await meshkeeGet<{ blog?: RawBlog }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {});
return normalizeBlog(data.blog);
}
export function blogImage(post: Pick<BlogPost, "titleImageUrl">) {
return post.titleImageUrl || "/images/articles/article-1.jpg";
}
export function blogHref(post: Pick<BlogPost, "id" | "title">) {
return detailPath("/blog", post.id, post.title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
export function blogExcerpt(post: BlogPost, max = 160) {
const text = stripHtml(post.abstract || post.mainTextHtml);
if (text.length <= max) return text;
return `${text.slice(0, max).trim()}…`;
}
+20
View File
@@ -0,0 +1,20 @@
export const websiteDomain =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
process.env.WEBSITE_DOMAIN ??
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
"drasadian.com";
export const meshkeeApiBase = (
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
process.env.MESHKEE_API_BASE ??
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
"https://api.meshkee.com/api/v1"
).replace(/\/$/, "");
export const customerPortalUrl = (
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}`
).replace(/\/$/, "");
export const businessDashboardUrl = (
process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}`
).replace(/\/$/, "");
+24
View File
@@ -0,0 +1,24 @@
export function faDigits(value: number | string) {
return String(value).replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)]);
}
export function formatFaDate(value?: string | null) {
if (!value) return "";
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return value;
}
}
export function stripHtml(value?: string | null) {
if (!value) return "";
return value
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
+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);
}
+33
View File
@@ -0,0 +1,33 @@
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}`;
}
type Tenant = {
id: number | string;
nameFa?: string;
name?: string;
domain?: string;
};
export async function getTenant() {
return meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null);
}
export async function getBusinessId() {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
}
+17
View File
@@ -0,0 +1,17 @@
/** Build a URL path segment from a title (Persian / Arabic / Latin). */
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { site } from "@/data/site";
import { websiteDomain } from "@/lib/config";
export const siteUrl = `https://${websiteDomain}`;
export function clipMetaDescription(text: string, max = 158) {
const clean = text.replace(/\s+/g, " ").trim();
if (clean.length <= max) return clean;
const cut = clean.slice(0, max - 1);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > 80 ? cut.slice(0, lastSpace) : cut).trim()}…`;
}
export function pageTitle(title: string, path = "/") {
if (path === "/" || title.includes(site.doctorName) || title.includes(site.name)) {
return title;
}
return `${title} | ${site.doctorName}`;
}
export function pageMetadata({
title,
description,
path = "/",
image,
}: {
title: string;
description: string;
path?: string;
image?: string | null;
}): Metadata {
const url = `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
const fullTitle = pageTitle(title, path);
const metaDescription = clipMetaDescription(description);
const ogImage = image || `${siteUrl}/images/logo.png`;
const isDetail = path.split("/").filter(Boolean).length > 1;
return {
title: { absolute: fullTitle },
description: metaDescription,
robots: { index: true, follow: true },
alternates: { canonical: url },
openGraph: {
type: isDetail ? "article" : "website",
locale: "fa_IR",
url,
siteName: site.name,
title: fullTitle,
description: metaDescription,
images: [{ url: ogImage, alt: fullTitle }],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description: metaDescription,
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;
}
+35
View File
@@ -0,0 +1,35 @@
{
"baseUrl": "https://drasadian.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.7
},
{
"path": "/contact",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/gallery",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/services",
"changefreq": "weekly",
"priority": 0.8
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}