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>
|
||||||
|
);
|
||||||
|
}
|
||||||