Initial commit: Clinic Samen Next.js site.

Clone of clinicsamen.com with Meshkee backend integration for blog, workshops, auth, static image slots, and SEO sitemap config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:40:31 +03:30
co-authored by Cursor
commit f19d09c27d
129 changed files with 17838 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# 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
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
+20
View File
@@ -0,0 +1,20 @@
Domain: brb-store.com
Documentation for backend is:https://api.meshkee.com/docs/website
Login or user icon should go to customer.{domainName}.com
When logged in, show user name + عزیز
Show a drop down for it with this item >> پروفایل من - خروج and if admin مدیریت وبسایت
Create workshops page and details >> connect to workshops
Details page:
- use light box gallery
- zoom image on hover
- has two tabs in bottom, description and comments. Comment is for authenticated user if not show a login link button or box
- and a registration row
Create blog page and details >> connect to blog
update context md for better use in other Ai and systems - update it on every prompt (If required)
+18
View File
@@ -0,0 +1,18 @@
Wire this page to clinicsamen.com, static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- 4 people image > 4 slot
- our-services-icon > 4 slot
- two-banner-bg > 2 slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+18
View File
@@ -0,0 +1,18 @@
### Sitemap + robots.txt
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
- Category: /products/category/{id}/{nameFaSlug} → GET /tenants/{domain}/categories/by-id/{id} (then list products with categoryId)
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
### On-page SEO (every public page)
- Unique <title> and meta description on every crawlable page
- Exactly one H1; H2/H3 for real sections
- All content images must have meaningful alt (CMS title when available)
- Prefer CMS fields for titles/descriptions; never leave Next defaults
+18
View File
@@ -0,0 +1,18 @@
Clone this website clinicsamen.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 and درمانگاران
we will read workshops and 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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
@font-face {
font-family: "iran-sans";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-regular.ttf');
}
@font-face {
font-family: "iran-sans-light";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-light.ttf');
}
@font-face {
font-family: "iran-sans-medium";
src:url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-medium.ttf');
}
@font-face {
font-family: "iran-sans-ultralight";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-ultralight.ttf');
}
@font-face {
font-family: "iran-sans-bold";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-bold.ttf');
}
@font-face {
font-family: "iran-sans-semibold";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-semibold.ttf');
}
@font-face {
font-family: "iran-sans-extrabold";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-extrabold.ttf');
}
@font-face {
font-family: "iran-sans-thin";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-thin.ttf');
}
@font-face {
font-family: "iran-sans-black";
src: url('https://cdn.meshkee.com/web-builder/fonts/iran-sans/iran-sans-black.ttf');
}
+1
View File
@@ -0,0 +1 @@
.wb-primary-color {color: var(--wb-primary-color);}.wb-primary-bg-color {background-color: var(--wb-primary-color);}.wb-secondary-color {color: var(--wb-secondary-color);}.wb-secondary-bg-color {background-color: var(--wb-secondary-color);}.wb-tertiary-color {color: var(--wb-tertiary-color);}.wb-tertiary-bg-color {background-color: var(--wb-tertiary-color);}
+59
View File
@@ -0,0 +1,59 @@
@font-face {
font-family: "montserrat";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-regular.ttf');
}
@font-face {
font-family: "montserrat-medium";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-medium.ttf');
}
@font-face {
font-family: "montserrat-light";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-light.ttf');
}
@font-face {
font-family: "montserrat-extralight";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-extralight.ttf');
}
@font-face {
font-family: "montserrat-bold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-bold.ttf');
}
@font-face {
font-family: "montserrat-semibold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-semibold.ttf');
}
@font-face {
font-family: "montserrat-extrabold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-extrabold.ttf');
}
@font-face {
font-family: "montserrat-thin";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-thin.ttf');
}
@font-face {
font-family: "montserrat-black";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-black.ttf');
}
@font-face {
font-family: "montserrat-alternates-regular";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-Regular.ttf');
}
@font-face {
font-family: "montserrat-alternates-light";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-Light.ttf');
}
@font-face {
font-family: "montserrat-alternates-extrabold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-ExtraBold.ttf');
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+153
View File
@@ -0,0 +1,153 @@
# Clinic Samen — AI / Project Context
## Source of truth
- Live site being cloned: https://www.clinicsamen.com/
- Prompt instructions: `Ai-data/prompt.txt`
- Backend / Meshkee Website API: https://api.meshkee.com/docs/website
- Static image slots prompt: `Ai-data/prompt-images-slot.txt`
- Backend connect prompt: `Ai-data/prompt-connect-backend.txt`
- SEO prompt: `Ai-data/prompt-seo.txt`
- Domain: `clinicsamen.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`)
- Tenant business id: `21` (from `GET /tenants/clinicsamen.com`)
## Stack
- Next.js 15 (App Router) + React 19 + TypeScript
- Tailwind CSS v4 (utility import) + custom CSS in `src/app/globals.css`
- Language: `fa`, direction: `rtl`
- Fonts (downloaded from Meshkee CDN into `public/fonts/`):
- `iran-sans` (default body / Persian UI)
- `montserrat` (English labels)
## Color palette (from live site CSS vars / accents)
- Primary soft blue: `#DFEAFE` (`--wb-primary-color`)
- Secondary / brand blue: `#0141C5` (`--wb-secondary-color`)
- Accent blue: `#0946C7`
- Mint accent: `#3ED2A7`
- Footer navy: `#0B1F4D`
- Muted text: `#707070`
## Pages
| Route | Purpose |
|-------|---------|
| `/` | Homepage |
| `/about` | درباره ما |
| `/contact` | تماس با ما |
| `/ourdr` | درمانگران |
| `/blog` | مقالات (Meshkee blogs API) |
| `/blog/{id}/{titleSlug}` | جزئیات مقاله |
| `/workshops` | کارگاه‌ها (Meshkee workshops API) |
| `/workshops/{slug}` | جزئیات کارگاه |
## Meshkee API integration
Base: `{NEXT_PUBLIC_MESHKEE_API_BASE}/tenants/{domain}/...`
Helpers: `src/lib/meshkee.ts` → `meshkeeGet`, `tenantPath`, `getTenant`, `getBusinessId`
### Blog
- List: `GET /blogs?page=&pageSize=`
- Detail: `GET /blogs/by-id/{id}` (canonical URL `/blog/{id}/{titleSlug}`)
- Legacy redirect: `/blog/{slug}` → canonical
- Comments: `GET /comments?entityType=blog&entityId={id}`
- Lib: `src/lib/blog.ts`
### Workshops
- List: `GET /workshops?page=&pageSize=`
- Detail: `GET /workshops/{slug}` (URL `/workshops/{slug}`)
- Register (auth): `POST /workshops/{workshopId}/register` via `src/app/api/workshops/[workshopId]/register/route.ts`
- Cancel registration (auth): `DELETE /workshops/{workshopId}/register`
- Comments: included on workshop detail when API provides `comments`; otherwise empty
- Lib: `src/lib/workshops.ts`
- **Note:** Tenant currently has no published workshops in API; homepage/list fall back to static samples in `src/data/site.ts`.
### Auth / customer portal
- Login icon → `https://customer.clinicsamen.com` (`customerPortalUrl` in `src/lib/config.ts`)
- Session: `src/lib/session.tsx` + shared cookies (`src/lib/shared-auth-cookie.ts`)
- Profile API proxy: `GET /api/auth/me` → Meshkee `/auth/me`
- Admin handoff: `POST /api/auth/handoff` → Meshkee `/auth/handoff` → `https://business.clinicsamen.com`
- Logged-in header: user name + «عزیز»; dropdown: پروفایل من، خروج، مدیریت وبسایت (admins only)
- Component: `src/components/AccountMenu.tsx`
## Static image slots
API: `GET /tenants/{domain}/website/static-images?pageKey=home`
Catalog for dashboard Refresh: `GET /meshkee/static-image-slots` → `STATIC_IMAGE_SLOTS` in `src/lib/static-images.ts`
| key | kind | itemCount | aspectRatio | recommendedWidth | homepage section |
| --- | --- | --- | --- | --- | --- |
| `slider` | list | null | `1920:740` | 1180 | Hero slider |
| `4-people-image` | list | 4 | `325:356` | 280 | Home therapists row |
| `our-services-icon` | list | 4 | `1:1` | 110 | Services icons |
| `two-banner-bg` | list | 2 | `540:312` | 560 | Dual promo banners |
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`)
- `url` → `<img src>`; `linkUrl` → wrap in link
- Empty / fail → keep local placeholders from `src/data/site.ts` / `public/images/`
- Use `slot.aspectRatio` (via `parseAspectRatio`)
- **If a slot’s on-page size or aspect ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change**
Helpers: `src/lib/static-images.ts`, `src/components/SlotImage.tsx`, `src/components/SlotLink.tsx`
Home wiring: `src/app/page.tsx`
## Content & assets
- Site copy, therapists bios, phones, address, socials live in `src/data/site.ts`
- Static workshop/article fallbacks in `src/data/site.ts` (used when API empty)
- Logos, banners, therapist photos, icons in `public/images/`
- Favicon: `/favicon.png`
## Footer Meshkee credit
- Bottom row matches Safeteb-style Meshkee 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`, `src/components/Providers.tsx`
- Header + account: `src/components/Header.tsx`, `src/components/AccountMenu.tsx`
- Footer: `src/components/Footer.tsx`
- Home: `src/app/page.tsx`, `src/components/HeroSlider.tsx`, `src/components/HomeSections.tsx`
- Blog: `src/app/blog/`, `src/components/ArticlesCarousel.tsx`
- Workshops: `src/app/workshops/`, `src/components/WorkshopGallery.tsx`, `src/components/WorkshopRegistration.tsx`, `src/components/EntityTabs.tsx`
- Config: `src/lib/config.ts`
- Data: `src/data/site.ts`
## Contact
- Phones: `025-32706211`, `025-32702623`, `09936221511`
- Address: بنیاد - میدان جوان - بلوار آفرینش - خیابان ذوالفقار - نبش کوچه ۳
- Socials: Instagram `@Psy.samen`, WhatsApp, Telegram, Eitaa
- Map embed: OpenStreetMap iframe on contact page
## Env
```bash
NEXT_PUBLIC_WEBSITE_DOMAIN=clinicsamen.com
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
# Optional overrides:
# NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://customer.clinicsamen.com
# NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.clinicsamen.com
```
## Commands
```bash
npm install
npm run dev
npm run build
```
## SEO canonical URLs
- Blog: `/blog/{id}/{titleSlug}` → `GET /blogs/by-id/{id}`
- Workshop: `/workshops/{slug}` → `GET /workshops/{slug}`
- Slugify helper: `src/lib/seo-slug.ts`
- Page metadata helper: `src/lib/seo.ts` → `pageMetadata()` (title, description, canonical, Open Graph)
## Sitemap + robots
- **Do not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API)
- Live sitemap index: `/sitemap.xml` → `/sitemap-main.xml` + CMS URLs
- Static pages config: `GET /meshkee/sitemap-config.json`
- Generated at build: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json`
- Run manually: `npm run generate:sitemap-config`
- After deploy: business dashboard → Website → Settings → Sync sitemap config
### On-page SEO rules
- Unique `<title>` + meta description on every public page (via `pageMetadata` or `generateMetadata`)
- Exactly one `<h1>` per page (homepage: header brand; inner pages: page hero)
- Section headings use `<h2>` / `<h3>`
- CMS images use meaningful `alt` (title when available)
+17
View File
@@ -0,0 +1,17 @@
# کلینیک مشاوره و روانشناسی ثامن
Next.js clone of [clinicsamen.com](https://www.clinicsamen.com/) (RTL / FA).
## Pages
- Home `/`
- About `/about`
- Contact `/contact`
- Therapists `/ourdr`
## Develop
```bash
npm install
npm run dev
```
See `CONTEXT.md` for palette, assets, and backend follow-ups.
+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;
+6223
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "clinic-samen",
"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;
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 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.
Binary file not shown.
Binary file not shown.
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: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 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 },
"/ourdr": { changefreq: "weekly", priority: 0.8 },
"/workshops": { changefreq: "weekly", priority: 0.8 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/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 ||
"clinicsamen.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();
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { aboutPoints, site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
title: "درباره ما",
description: `درباره ${site.name}: ${site.description}`,
path: "/about",
});
export default function AboutPage() {
return (
<>
<div className="page-hero">
<div className="container">
<h1>درباره ما</h1>
<div className="breadcrumb">
<Link href="/">خانه</Link> / درباره ما
</div>
</div>
</div>
<section className="section">
<div className="container about-layout">
<div>
<h2 style={{ marginTop: 0, color: "var(--wb-accent)" }}>کلینیک روانشناسی ثامن</h2>
<p style={{ color: "#555", textAlign: "justify" }}>{site.tagline}</p>
<p style={{ color: "#555", textAlign: "justify" }}>{site.description}</p>
<ol className="about-points">
{aboutPoints.map((point, index) => (
<li key={point}>
{index + 1} - {point}
</li>
))}
</ol>
<div style={{ marginTop: "1.5rem" }}>
<Link href="/contact" className="btn btn-primary">
تماس با ما
</Link>
</div>
</div>
<div className="visual">
<Image
src="/images/about/about-banner.png"
alt="کلینیک روانشناسی ثامن"
width={640}
height={520}
/>
</div>
</div>
</section>
</>
);
}
+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);
}
@@ -0,0 +1,44 @@
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
import { tenantPath } from "@/lib/meshkee";
export async function POST(
request: Request,
context: { params: Promise<{ workshopId: string }> },
) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const { workshopId } = await context.params;
const res = await meshkeeFetch(tenantPath(`/workshops/${encodeURIComponent(workshopId)}/register`), {
method: "POST",
token,
});
if (!res.ok) {
const data = res.data as { message?: string } | null;
return jsonResponse({ error: data?.message ?? "ثبت‌نام انجام نشد." }, res.status);
}
return jsonResponse(res.data, res.status);
}
export async function DELETE(
request: Request,
context: { params: Promise<{ workshopId: string }> },
) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const { workshopId } = await context.params;
const res = await meshkeeFetch(tenantPath(`/workshops/${encodeURIComponent(workshopId)}/register`), {
method: "DELETE",
token,
});
if (!res.ok) {
const data = res.data as { message?: string } | null;
return jsonResponse({ error: data?.message ?? "لغو ثبت‌نام انجام نشد." }, res.status);
}
return jsonResponse(res.data);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import EntityTabs from "@/components/EntityTabs";
import PageHero from "@/components/PageHero";
import { blogHref, blogImage, blogTitleSlug, fetchBlogById, getLocalBlogPost } from "@/lib/blog";
import { fetchComments } from "@/lib/comments";
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 apiPost = await fetchBlogById(id);
if (apiPost) {
return pageMetadata({
title: apiPost.title,
description: apiPost.abstract ?? apiPost.title,
path: blogHref(apiPost),
image: blogImage(apiPost),
});
}
const local = getLocalBlogPost(id);
if (!local) return { title: "مقاله" };
return pageMetadata({
title: local.title,
description: local.abstract ?? local.title,
path: blogHref(local),
image: blogImage(local),
});
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ id: string; slug: string }>;
}) {
const { id, slug } = await params;
const apiPost = await fetchBlogById(id);
if (apiPost) {
const canonical = blogTitleSlug(apiPost.title);
if (decodeURIComponent(slug) !== canonical) {
redirect(blogHref(apiPost));
}
const comments = await fetchComments("blog", apiPost.id);
return (
<>
<PageHero title={apiPost.title} image={blogImage(apiPost)} />
<section className="section">
<div className="container detail-page">
<p className="meta-line">{formatFaDate(apiPost.publishedAt || apiPost.createdAt)}</p>
{apiPost.abstract ? <p className="detail-lead">{apiPost.abstract}</p> : null}
{apiPost.mainTextHtml ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: apiPost.mainTextHtml }} />
) : null}
<EntityTabs showDescription={false} comments={comments} />
<Link href="/blog" className="back-link">
بازگشت به مقالات
</Link>
</div>
</section>
</>
);
}
const local = getLocalBlogPost(id);
if (!local) notFound();
const canonical = blogTitleSlug(local.title);
if (decodeURIComponent(slug) !== canonical) {
redirect(blogHref(local));
}
return (
<>
<PageHero title={local.title} image={blogImage(local)} />
<section className="section">
<div className="container detail-page">
{local.abstract ? <p className="detail-lead">{local.abstract}</p> : null}
<div className="detail-image">
<Image src={blogImage(local)} alt={local.title} width={960} height={540} />
</div>
<Link href="/blog" className="back-link">
بازگشت به مقالات
</Link>
</div>
</section>
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { notFound, redirect } from "next/navigation";
import { blogHref, fetchBlogBySlug, getLocalBlogPost } 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 apiPost = await fetchBlogBySlug(id);
if (apiPost) redirect(blogHref(apiPost));
const local = getLocalBlogPost(id);
if (local) redirect(blogHref(local));
notFound();
}
+78
View File
@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { blogHref, blogImage, fetchBlogs, getLocalBlogPosts } 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",
});
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 usingApi = result.items.length > 0 && !String(result.items[0]?.id).startsWith("local-");
const posts = usingApi ? result.items : getLocalBlogPosts();
const pageCount = usingApi
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
: 1;
return (
<>
<PageHero
title="مقالات"
subtitle="آخرین مطالب آموزشی کلینیک ثامن"
image="/images/blog/post-1.jpg"
/>
<section className="section">
<div className="container">
<div className="card-grid">
{posts.map((post) => (
<Link key={post.id} href={blogHref(post)} className="media-card media-card--link">
<div className="thumb">
<Image src={blogImage(post)} alt={post.title} width={400} height={275} />
</div>
<div className="body">
<p className="meta-line">{formatFaDate(post.publishedAt || post.createdAt)}</p>
<h2>{post.title}</h2>
{post.abstract ? <p>{post.abstract}</p> : null}
<div className="meta">
<span>مشاهده بیشتر</span>
</div>
</div>
</Link>
))}
</div>
{pageCount > 1 ? (
<div className="pagination">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
return (
<Link
key={number}
href={`/blog?page=${number}`}
className={number === page ? "active" : undefined}
>
{faDigits(number)}
</Link>
);
})}
</div>
) : null}
</div>
</section>
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import Link from "next/link";
import ContactForm from "@/components/ContactForm";
import { site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
title: "تماس با ما",
description: `تماس با ${site.name} — ${site.address} — تلفن ${site.phone}`,
path: "/contact",
});
export default function ContactPage() {
return (
<>
<div className="page-hero">
<div className="container">
<h1>تماس با ما</h1>
<div className="breadcrumb">
<Link href="/">خانه</Link> / تماس با ما
</div>
</div>
</div>
<section className="section">
<div className="container contact-layout">
<div className="contact-card">
<h2>تماس با کلینیک ثامن</h2>
<ul className="contact-list">
<li>
<strong>آدرس:</strong> {site.address}
</li>
{site.phones.map((phone) => (
<li key={phone}>
<strong>تلفن:</strong>{" "}
<a href={`tel:${phone.replace(/-/g, "")}`}>{phone}</a>
</li>
))}
<li>
<strong>ساعات کاری:</strong> {site.hours}
</li>
</ul>
<div className="map-frame">
<iframe title="موقعیت کلینیک ثامن" src={site.mapEmbed} loading="lazy" />
</div>
</div>
<ContactForm />
</div>
</section>
</>
);
}
+1938
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
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: "کلینیک مشاوره و روانشناسی ثامن قم",
template: "%s | کلینیک ثامن قم",
},
description: site.description,
icons: {
icon: "/favicon.png",
},
openGraph: {
locale: "fa_IR",
siteName: site.shortName,
type: "website",
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const businessId = await getBusinessId();
return (
<html lang="fa" dir="rtl">
<body>
<Providers businessId={businessId}>
<div className="wrapper">
<Header />
<main>{children}</main>
<Footer />
</div>
</Providers>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
@@ -0,0 +1,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { site, therapists } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
title: "درمانگران",
description: `درمانگران و مشاوران ${site.name} — رزرو نوبت مشاوره فردی، خانواده و ازدواج در قم.`,
path: "/ourdr",
});
export default function TherapistsPage() {
return (
<>
<div className="page-hero">
<div className="container">
<h1>درمانگران</h1>
<div className="breadcrumb">
<Link href="/">خانه</Link> / درمانگران
</div>
</div>
</div>
<section className="section">
<div className="container">
<div className="section-head">
<h2>مشاوران کلینیک روانشناسی ثامن</h2>
<p>جهت رزرو نوبت مشاوره تماس بگیرید</p>
</div>
<div className="therapist-page-grid">
{therapists.map((item) => (
<article key={item.name} className="therapist-detail">
<div className="photo">
<Image src={item.image} alt={item.name} width={420} height={440} />
</div>
<div className="body">
<h3>{item.name}</h3>
<p>{item.bio}</p>
</div>
</article>
))}
</div>
</div>
</section>
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
import HeroSlider from "@/components/HeroSlider";
import HomeSections from "@/components/HomeSections";
import { homeTherapists, serviceCards, site, slides } from "@/data/site";
import { fetchBlogs, getLocalBlogPosts } from "@/lib/blog";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
import { fetchWorkshops } from "@/lib/workshops";
export const revalidate = 60;
export const metadata = pageMetadata({
title: "کلینیک مشاوره و روانشناسی ثامن قم",
description: `${site.description} — مشاوره فردی، خانواده، ازدواج، کودک و کارگاه‌های تخصصی در قم.`,
path: "/",
});
export default async function HomePage() {
const [slots, blogResult, workshopResult] = await Promise.all([
fetchStaticImageSlots("home"),
fetchBlogs({ pageSize: 8 }),
fetchWorkshops({ pageSize: 4 }),
]);
const blogPosts =
blogResult.items.length > 0 && !String(blogResult.items[0]?.id).startsWith("local-")
? blogResult.items
: getLocalBlogPosts();
const workshops = workshopResult.items;
const slider = overlaySlotImages(
pickSlot(slots, "slider"),
slides.map((slide, index) => {
const item = slide as { image: string; title: string; english: string; alt: string };
return {
id: `slide-${index}`,
image: item.image,
title: item.alt || item.title,
kicker: item.english,
};
}),
"slider",
);
const people = overlaySlotImages(
pickSlot(slots, "4-people-image"),
homeTherapists.map((person, index) => ({
id: `person-${index}`,
image: person.image,
href: "/ourdr",
title: person.name,
text: person.role,
})),
"4-people-image",
);
const serviceIcons = overlaySlotImages(
pickSlot(slots, "our-services-icon"),
serviceCards.map((service, index) => ({
id: `service-${index}`,
image: service.image,
href: service.href,
title: service.title,
kicker: service.english,
})),
"our-services-icon",
);
const banners = overlaySlotImages(
pickSlot(slots, "two-banner-bg"),
[
{
id: "banner-workshops",
image: "/images/banners/workshops.jpg",
href: "/workshops",
title: "کارگاه عمومی",
},
{
id: "banner-individual",
image: "/images/banners/individual.jpg",
href: "/contact",
title: "مشاوره فردی",
},
],
"two-banner-bg",
);
return (
<>
<HeroSlider slides={slider.items} aspectRatio={slider.aspectRatio} />
<HomeSections
people={people.items}
peopleAspectRatio={people.aspectRatio}
serviceIcons={serviceIcons.items}
serviceIconsAspectRatio={serviceIcons.aspectRatio}
banners={banners.items}
bannersAspectRatio={banners.aspectRatio}
workshops={workshops}
articles={blogPosts}
/>
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import EntityTabs from "@/components/EntityTabs";
import PageHero from "@/components/PageHero";
import WorkshopGallery from "@/components/WorkshopGallery";
import WorkshopRegistration from "@/components/WorkshopRegistration";
import { formatFaDate } from "@/lib/format";
import { fetchWorkshopBySlug, workshopHref, workshopImage, workshopImages } from "@/lib/workshops";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const workshop = await fetchWorkshopBySlug(slug);
if (!workshop) return { title: "کارگاه" };
return pageMetadata({
title: workshop.title,
description: workshop.abstract ?? workshop.title,
path: workshopHref(workshop),
image: workshopImage(workshop),
});
}
export default async function WorkshopDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const workshop = await fetchWorkshopBySlug(slug);
if (!workshop) notFound();
const comments = workshop.comments ?? [];
return (
<>
<PageHero title={workshop.title} subtitle={workshop.abstract ?? undefined} />
<section className="section">
<div className="container detail-page">
<div className="workshop-detail">
<WorkshopGallery images={workshopImages(workshop)} alt={workshop.title} />
<div className="workshop-detail__content">
{workshop.eventDate ? (
<p className="meta-line">تاریخ: {formatFaDate(workshop.eventDate)}</p>
) : null}
{workshop.abstract ? <p className="detail-lead">{workshop.abstract}</p> : null}
<WorkshopRegistration
workshopId={workshop.id}
eventDate={workshop.eventDate}
eventTime={workshop.eventTime}
duration={workshop.duration}
status={workshop.status}
/>
</div>
</div>
<EntityTabs
descriptionHtml={workshop.mainTextHtml}
comments={comments}
showDescription
/>
<Link href="/workshops" className="back-link">
بازگشت به کارگاه‌ها
</Link>
</div>
</section>
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
import Image from "next/image";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { faDigits, formatFaDate } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
import { fetchWorkshops, workshopHref, workshopImage } from "@/lib/workshops";
export const revalidate = 60;
export const metadata = pageMetadata({
title: "کارگاه‌ها",
description: "کارگاه‌های تخصصی کلینیک روانشناسی و مرکز مشاوره ثامن قم",
path: "/workshops",
image: "/images/banners/workshops.jpg",
});
export default async function WorkshopsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const result = await fetchWorkshops({ page, pageSize: 12 });
const usingApi = result.items.length > 0 && !String(result.items[0]?.id).startsWith("local-");
const pageCount = usingApi
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
: 1;
return (
<>
<PageHero
title="کارگاه‌های تخصصی"
subtitle="کارگاه‌های در حال ثبت‌نام کلینیک ثامن"
image="/images/banners/workshops.jpg"
/>
<section className="section section-muted">
<div className="container">
<div className="card-grid">
{result.items.map((item) => (
<Link key={item.id} href={workshopHref(item)} className="media-card media-card--link">
<div className="thumb">
<Image src={workshopImage(item)} alt={item.title} width={400} height={275} />
</div>
<div className="body">
{item.tag ? <span className="tag">{item.tag}</span> : null}
<h2>{item.title}</h2>
{item.abstract ? <p>{item.abstract}</p> : null}
<div className="meta">
<span>{item.status || "جزئیات رویداد"}</span>
{item.eventDate ? <span>{formatFaDate(item.eventDate)}</span> : null}
</div>
</div>
</Link>
))}
</div>
{pageCount > 1 ? (
<div className="pagination">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
return (
<Link
key={number}
href={`/workshops?page=${number}`}
className={number === page ? "active" : undefined}
>
{faDigits(number)}
</Link>
);
})}
</div>
) : null}
</div>
</section>
</>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
import { useSession, userDisplayName } from "@/lib/session";
const itemClass =
"block w-full px-3.5 py-2.5 text-right text-sm text-[var(--wb-text)] transition-colors hover:bg-[var(--wb-primary-color)] hover:text-[var(--wb-accent)]";
function UserIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.75" />
<path
d="M5.5 19.25c1.4-3 3.8-4.5 6.5-4.5s5.1 1.5 6.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="14"
height="14"
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) {
return (
<span className="account-menu account-menu--loading" aria-hidden>
<UserIcon />
</span>
);
}
if (!isAuthenticated || !user) {
return (
<a href={customerPortalUrl} className="account-menu account-menu--login" aria-label="ورود / ثبت‌نام">
<UserIcon />
<span>ورود / ثبت‌نام</span>
</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`}
className={itemClass}
onClick={() => setOpen(false)}
>
پروفایل من
</a>
{isWebsiteAdmin ? (
<a
role="menuitem"
href={businessDashboardUrl}
className={itemClass}
onClick={openBusinessDashboard}
>
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
</a>
) : null}
<button
type="button"
role="menuitem"
className={`${itemClass} account-menu__logout`}
onClick={() => {
setOpen(false);
logout();
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState, type CSSProperties } from "react";
import { articles } from "@/data/site";
import { blogHref, blogImage, type BlogPost } from "@/lib/blog";
function Chevron({ direction }: { direction: "prev" | "next" }) {
return (
<svg width="14" height="22" viewBox="0 0 14 22" fill="none" aria-hidden>
<path
d={direction === "prev" ? "M12 2L3 11l9 9" : "M2 2l9 9-9 9"}
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
type CarouselItem = {
id: string;
title: string;
excerpt?: string | null;
image: string;
href: string;
};
function toCarouselItems(posts: BlogPost[]): CarouselItem[] {
return posts.map((post) => ({
id: post.id,
title: post.title,
excerpt: post.abstract,
image: blogImage(post),
href: blogHref(post),
}));
}
function staticCarouselItems(): CarouselItem[] {
return articles.map((item, index) => ({
id: `static-${index}`,
title: item.title,
excerpt: item.excerpt,
image: item.image,
href: "/blog",
}));
}
export default function ArticlesCarousel({ posts = [] }: { posts?: BlogPost[] }) {
const items = posts.length ? toCarouselItems(posts) : staticCarouselItems();
const [index, setIndex] = useState(0);
const [perView, setPerView] = useState(4);
useEffect(() => {
const update = () => {
const width = window.innerWidth;
if (width <= 575) setPerView(1);
else if (width <= 860) setPerView(2);
else setPerView(4);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
const maxIndex = Math.max(0, items.length - perView);
useEffect(() => {
setIndex((current) => Math.min(current, maxIndex));
}, [maxIndex]);
useEffect(() => {
if (maxIndex === 0) return;
const id = window.setInterval(() => {
setIndex((current) => (current >= maxIndex ? 0 : current + 1));
}, 3500);
return () => window.clearInterval(id);
}, [maxIndex]);
function go(delta: number) {
setIndex((current) => {
const next = current + delta;
if (next < 0) return maxIndex;
if (next > maxIndex) return 0;
return next;
});
}
return (
<div className="articles-carousel" style={{ "--per-view": perView } as CSSProperties}>
<div className="articles-carousel__viewport">
<div
className="articles-carousel__track"
style={{
transform: `translateX(${index * (100 / perView)}%)`,
}}
>
{items.map((item) => (
<div key={item.id} className="articles-carousel__item">
<Link href={item.href} className="media-card media-card--link">
<div className="thumb">
<Image src={item.image} alt={item.title} width={400} height={275} />
</div>
<div className="body">
<h3>{item.title}</h3>
{item.excerpt ? <p>{item.excerpt}</p> : null}
<div className="meta">
<span>مشاهده بیشتر</span>
</div>
</div>
</Link>
</div>
))}
</div>
</div>
<div className="articles-carousel__controls" dir="ltr">
<button type="button" aria-label="قبلی" onClick={() => go(-1)}>
<Chevron direction="prev" />
</button>
<button type="button" aria-label="بعدی" onClick={() => go(1)}>
<Chevron direction="next" />
</button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More