fix: resolve sitemap build error caused by invalid Date values
- Replace new Date(b.created) with toSafeDate() helper to guard against invalid date strings from blog API responses - Use a single NOW constant instead of new Date() per entry to avoid serialization issues during static generation - Remove unused imports (cityData, DOMAIN_CONFIG, etc.) - Add CLAUDE.md with project architecture and development guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
35408b059a
commit
ec536bfc32
@@ -0,0 +1,124 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server at http://yazd-nobat.localhost:3000 (sets HOST via cross-env)
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
The dev script forces `HOST=yazd-nobat.localhost` so multi-domain detection works locally. To test a different city subdomain, temporarily change `HOST` in the script.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=https://api.clinic-pro.ir # Backend API base URL
|
||||
NEXT_PUBLIC_CLIENT_ID=... # OAuth client ID
|
||||
NEXT_PUBLIC_CLIENT_SECRET=... # OAuth client secret
|
||||
DEV_MODE=TRUE # TRUE → blocks all crawlers + noindex
|
||||
```
|
||||
|
||||
`DEV_MODE=TRUE` disables robots indexing and adds `noindex` metadata. Set to `FALSE` in production.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Next.js 15 App Router** with MUI v5 + Tailwind CSS, RTL (Persian/Farsi), Jalali calendar.
|
||||
|
||||
### Multi-Domain System (Core Concept)
|
||||
|
||||
Each city has its own domain (e.g. `yazd-nobat.ir`, `tabriz-nobat.ir`). The codebase serves all cities from one deployment, detecting which city to show via subdomain.
|
||||
|
||||
- **`data/city.json`** — source of truth: every city's `domain`, `title`, `description`, `keywords`, `site_name`, `socialMedia`, etc.
|
||||
- **`lib/getStateInfo.js`** — server-side: reads `host` header, extracts subdomain, matches against `city.json`. Use in Server Components and `generateMetadata`.
|
||||
- **`context/ProvinceProvider.js`** — client-side equivalent using `window.location.hostname`. Exposes `useProvince()` → `{ isProvinceInclude }`.
|
||||
- **`lib/getCanonicalUrl.js`** — reads `x-pathname` header set by middleware for canonical URL generation.
|
||||
- **`middleware.js`** — injects `x-pathname` header into every response so `getCanonicalUrl` can read it.
|
||||
|
||||
### Page Metadata Pattern
|
||||
|
||||
Every page must export `generateMetadata`. Layout-level metadata is the fallback:
|
||||
|
||||
```js
|
||||
// app/layout.js — sets title/description/OG/Twitter from matchedCity
|
||||
export async function generateMetadata() {
|
||||
const { matchedCity } = await getStateInfo();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Page-level `generateMetadata` overrides layout for dynamic pages (doctor, blog, clinic):
|
||||
|
||||
```js
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params; // Always await params in Next.js 15
|
||||
// fetch data, build title/description, return metadata object
|
||||
}
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
|
||||
**Server Components** use:
|
||||
- `lib/req.js` → `fetchReq(url)` — axios with SSL verification disabled (needed for dev backend)
|
||||
- `axios` directly for cases needing more control
|
||||
|
||||
**Client Components** use:
|
||||
- `services/api.js` — axios instance with `baseURL = NEXT_PUBLIC_API_URL`
|
||||
- `services/response.js` → `request.*` — all API call wrappers. Pass `{ requireAuth: true }` to attach the `access_token` cookie as `Authorization: Bearer`.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
Auth uses JWT stored in cookies: `access_token`, `refresh_token`, `uuid`, `userInfo`.
|
||||
|
||||
- **`lib/auth.js`** → `getUser()` — reads cookies server-side
|
||||
- **`lib/ability.js`** → `defineAbilitiesFor(user)` — CASL rules. Roles: `"representation"` → Panel access
|
||||
- Protected pages call `getUser()` + `defineAbilitiesFor()` and redirect if unauthorized
|
||||
|
||||
Login flow: POST `/api/v1/user/send-code` (OTP) → POST `oauth/token` → set cookies.
|
||||
|
||||
### Styling
|
||||
|
||||
- **Tailwind CSS** with `darkMode: "class"` — public pages use `data-theme` attribute, panel uses `class`
|
||||
- **MUI theme** configured in `mui/index.js` with RTL direction and Vazir font
|
||||
- **Font**: Vazir only — defined in `app/globals.css` via `@font-face` with `font-display: swap`. No other fonts.
|
||||
- Custom CSS classes in `globals.css`: `.bg-banner-home`, `.bg-banner-footer`, `.padding-responsive`, etc.
|
||||
- Dark mode toggled by `next-themes` in `app/Providers.js`: public = `attribute="data-"`, panel = `attribute="class"`
|
||||
|
||||
### Routing Structure
|
||||
|
||||
```
|
||||
app/
|
||||
layout.js # Root layout: metadata, ThemeRegistry, ProvinceProvider
|
||||
page.js # Home → components/home/
|
||||
robots.js # Blocks all when DEV_MODE=TRUE
|
||||
sitemap.js # Fetches doctors/clinics/blogs from API at runtime
|
||||
doctor/[slug]/page.js # generateMetadata + JSON-LD (Physician schema)
|
||||
clinic/[slug]/page.js # generateMetadata + JSON-LD (MedicalClinic schema)
|
||||
blog/[slug]/page.js # generateMetadata + JSON-LD (Article schema)
|
||||
doctors/page.js # generateMetadata using matchedCity
|
||||
clinics/page.js # generateMetadata using matchedCity
|
||||
panel/(layout)/ # Route group — requires "representation" role
|
||||
```
|
||||
|
||||
All public pages wrap content in `<Layout name="/path">` from `components/layout/StLayout.js` (header + footer). Panel pages use `components/layoutPanel/`.
|
||||
|
||||
### Key Data Files
|
||||
|
||||
- `data/city.json` — city configs including domain, SEO fields, social media links
|
||||
- `data/state.json` — province/state data, joined to city via `province_id`
|
||||
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
|
||||
|
||||
### Doctor & Clinic Slugs
|
||||
|
||||
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
|
||||
|
||||
### JSON-LD Structured Data
|
||||
|
||||
Added directly in page JSX (not via metadata API):
|
||||
```jsx
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
```
|
||||
Present on `/doctor/[slug]` (Physician), `/clinic/[slug]` (MedicalClinic), `/blog/[slug]` (Article).
|
||||
+23
-27
@@ -1,39 +1,35 @@
|
||||
import { headers } from 'next/headers';
|
||||
import cityData from '../data/city.json';
|
||||
import specialtyData from '../data/specialties.json';
|
||||
import {
|
||||
DOMAIN_CONFIG,
|
||||
isMainDomain,
|
||||
createSitemapUrl,
|
||||
createSitemapEntry,
|
||||
limitArray,
|
||||
getBaseUrl
|
||||
} from '../utils/sitemap';
|
||||
import { getBaseUrl } from '../utils/sitemap';
|
||||
|
||||
function toSafeDate(value) {
|
||||
if (!value) return new Date();
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
}
|
||||
|
||||
const MAIN_DOMAIN = 'nobat724.com';
|
||||
|
||||
function getCurrentDomain() {
|
||||
try {
|
||||
const headersList = headers();
|
||||
const host = headersList.get('host');
|
||||
return host || DOMAIN_CONFIG.MAIN_DOMAIN;
|
||||
} catch (error) {
|
||||
console.warn('Headers not available during static generation, using main domain');
|
||||
return DOMAIN_CONFIG.MAIN_DOMAIN;
|
||||
return host || MAIN_DOMAIN;
|
||||
} catch {
|
||||
return MAIN_DOMAIN;
|
||||
}
|
||||
}
|
||||
|
||||
function getCityByDomain(domain) {
|
||||
return cityData.find(city => city.domain === domain);
|
||||
}
|
||||
const NOW = new Date();
|
||||
|
||||
function getStaticPages(baseUrl) {
|
||||
return [
|
||||
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
|
||||
{ url: `${baseUrl}/about-us`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ url: `${baseUrl}/contact-us`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ url: `${baseUrl}/blogs`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${baseUrl}/doctors`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
|
||||
{ url: `${baseUrl}/clinics`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
|
||||
{ url: `${baseUrl}/specialties`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: baseUrl, lastModified: NOW, changeFrequency: 'daily', priority: 1 },
|
||||
{ url: `${baseUrl}/about-us`, lastModified: NOW, changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ url: `${baseUrl}/contact-us`, lastModified: NOW, changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ url: `${baseUrl}/blogs`, lastModified: NOW, changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${baseUrl}/doctors`, lastModified: NOW, changeFrequency: 'daily', priority: 0.9 },
|
||||
{ url: `${baseUrl}/clinics`, lastModified: NOW, changeFrequency: 'daily', priority: 0.9 },
|
||||
{ url: `${baseUrl}/specialties`, lastModified: NOW, changeFrequency: 'weekly', priority: 0.9 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,7 +48,7 @@ async function getDoctorUrls(baseUrl) {
|
||||
.filter((d) => d?.uuid)
|
||||
.map((d) => ({
|
||||
url: `${baseUrl}/doctor/${d.uuid}`,
|
||||
lastModified: new Date(),
|
||||
lastModified: NOW,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
@@ -76,7 +72,7 @@ async function getClinicUrls(baseUrl) {
|
||||
.filter((c) => c?.uuid)
|
||||
.map((c) => ({
|
||||
url: `${baseUrl}/clinic/${c.uuid}`,
|
||||
lastModified: new Date(),
|
||||
lastModified: NOW,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
}));
|
||||
@@ -100,7 +96,7 @@ async function getBlogUrls(baseUrl) {
|
||||
.filter((b) => b?.slug || b?.uuid)
|
||||
.map((b) => ({
|
||||
url: `${baseUrl}/blog/${b.slug || b.uuid}`,
|
||||
lastModified: b.created ? new Date(b.created) : new Date(),
|
||||
lastModified: toSafeDate(b.created),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user