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>
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -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();
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/handoff", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
|
||||
}
|
||||
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/me", { token });
|
||||
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||