feat(doctor): enhance doctor schema with addresses, ratings, and social media links
feat(robots): update disallow rules to include '/panel' feat(sitemap): implement separate sitemaps for doctors, clinics, and blogs feat(icons): add social media icons for doctor profiles
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
# SEO پیشرفته صفحه پزشک: Schema کامل، Sitelinks، Knowledge Panel، Local SEO
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front` — این پرامپت برای فعالسازی Sitelinks و Knowledge Panel گوگل برای صفحهی هر پزشک است (هدف: نتیجهی جستجوی نام پزشک شبیه نتایج Zocdoc/Healthgrades با Knowledge Panel، عکس، شبکههای اجتماعی، آدرس، ساعات کاری، امتیاز).
|
||||
|
||||
**وابستگی cross-repo:** فیلد `socialMedia` پزشک هنوز در backend وجود ندارد — پرامپت همتا در `clinicpro/.claude/prompt/doctor-social-media-field.md` این فیلد را اضافه میکند. **آن پرامپت را اول اجرا کن**، سپس این یکی را، چون بخشهای `sameAs` JSON-LD و کارت شبکههای اجتماعی در UI به آن فیلد نیاز دارند. اگر هنوز اجرا نشده، آن بخشها را با حالت "اگر `doctor.socialMedia` وجود داشت" مشروط بنویس تا بدون خطا گریسفول fallback شود.
|
||||
|
||||
## زمینه
|
||||
|
||||
بررسی کد واقعی نشان داد:
|
||||
- `app/doctor/[slug]/page.js` فعلاً فقط schema `Physician` ساده (نام، تخصص، عکس، آدرس متنی) و یک `BreadcrumbList` دارد.
|
||||
- دادهی واقعی API (`docs/api/doctor.md`) شامل: `point`/`satisfaction` (امتیاز و رضایت، میتواند `AggregateRating` شود)، چندین `address[]` با `clinics[]` (هرکدام `name`/`address`/`telephone`)، اما این آرایه مستقیماً در `getDoctor()` فعلی fetch نمیشود (endpoint جدا: `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` که `map.latitude`/`map.longitude` هم دارد).
|
||||
- نظرات از `GET /api/v1/comments/{uuid}` میآید (`comments` در کامپوننت `Doctor()`) — میتواند منبع `Review` schema باشد.
|
||||
- مقالات/پرسشوپاسخ پزشک در حال حاضر در هیچجای کد دیده نشد — این پروژه فعلاً **مقاله یا FAQ مرتبط به پزشک خاص ندارد** (فقط `app/blogs`/`app/blog/[slug]` عمومی است، بدون ارتباط به یک پزشک مشخص). بخشهای مرتبط با Article/FAQPage در این پرامپت را فقط برای ساختار سایت عمومی (نه per-doctor) طراحی کن، مگر اینکه در حین اجرا API برای FAQ پزشک پیدا شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
برای `app/doctor/[slug]/page.js` (و در حد لازم `app/doctors/page.js`, `app/clinics/page.js`, `app/sitemap.js`, `app/robots.js`, `app/layout.js`) موارد زیر اضافه/تکمیل شود:
|
||||
1. Schema.org کاملتر برای پزشک (Physician + AggregateRating + Review + LocalBusiness برای هر مطب + ImageObject + sameAs)
|
||||
2. Sitemap تفکیکشده (پزشکان، شهرها، تخصصها، مقالات) بهجای فایل واحد فعلی
|
||||
3. ساختار URL/Internal Linking برای افزایش شانس Sitelinks
|
||||
4. متادیتای دقیقتر هماهنگ با Knowledge Panel
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش فعلی | تغییر لازم |
|
||||
|------|----------|------------|
|
||||
| `app/doctor/[slug]/page.js` | `getDoctor()` با `cache()`، schema `Physician` ساده، breadcrumb | افزودن AggregateRating, Review, LocalBusiness(×N مطب), ImageObject, sameAs؛ fetch کردن آدرسهای مطب |
|
||||
| `components/doctor/index.js` | رندر breadcrumb بصری (تخصص > نام) - قبلاً به Link تبدیل شده | بدون تغییر مگر اضافهکردن نمایش شبکههای اجتماعی اگر `doctor.socialMedia` وجود داشت |
|
||||
| `app/sitemap.js` | یک فایل واحد، `getDoctorUrls`/`getClinicUrls`/`getBlogUrls` با `fetch` + `revalidate: 3600` | تفکیک به چند sitemap با `app/sitemap/[id]/route.js` یا multi-sitemap pattern رسمی Next.js 15 (`generateSitemaps`) |
|
||||
| `app/robots.js` | `disallow: '/dashboard'` فقط؛ از `DEV_MODE` میخواند | اضافهکردن `/panel` به disallow؛ بدون تغییر در منطق DEV_MODE |
|
||||
| `app/layout.js` | JSON-LD سراسری `Organization` + `WebSite` با `SearchAction` | بررسی کن `sameAs` سازمانی (لینک شبکههای اجتماعی نوبت724، نه پزشک) باید اینجا اضافه شود اگر `data/city.json` چنین دادهای دارد |
|
||||
| `clinicpro/docs/api/doctor.md` | مرجع شکل دقیق پاسخ API | فقط مرجع خوانده شود، تغییر ندهید (این فایل پروژه backend است) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### `app/doctor/[slug]/page.js` — schema فعلی (ناقص)
|
||||
|
||||
```js
|
||||
const jsonLd = doctor
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Physician",
|
||||
name: `دکتر ${doctor.name}`,
|
||||
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
||||
...(doctor.img && { image: doctor.img }),
|
||||
...(doctor.address && {
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: doctor.address,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
```
|
||||
|
||||
مشکلات این بلوک:
|
||||
- `image` باید `ImageObject` کامل (با `url`, `width`, `height`) باشد، نه رشتهی خام.
|
||||
- `address` فقط یک متن ساده است؛ مطبهای واقعی پزشک (با مختصات جغرافیایی) اصلاً fetch نمیشوند در این صفحه.
|
||||
- بدون `aggregateRating`، بدون `review`، بدون `sameAs`، بدون `telephone`.
|
||||
- `doctor.point`/`doctor.satisfaction` (که در پاسخ API موجود است طبق `docs/api/doctor.md`) استفاده نمیشوند.
|
||||
|
||||
### `app/sitemap.js` — فعلاً یک فایل واحد
|
||||
|
||||
```js
|
||||
export default async function sitemap() {
|
||||
try {
|
||||
const domain = getCurrentDomain();
|
||||
const baseUrl = getBaseUrl(domain);
|
||||
const [staticPages, doctorUrls, clinicUrls, blogUrls] = await Promise.all([
|
||||
Promise.resolve(getStaticPages(baseUrl)),
|
||||
getDoctorUrls(baseUrl),
|
||||
getClinicUrls(baseUrl),
|
||||
getBlogUrls(baseUrl),
|
||||
]);
|
||||
const allUrls = [...staticPages, ...doctorUrls, ...clinicUrls, ...blogUrls];
|
||||
return allUrls.filter(/* dedupe */);
|
||||
} catch { return []; }
|
||||
}
|
||||
```
|
||||
|
||||
این تابع همهچیز را در یک فایل XML واحد میریزد (`getDoctorUrls` با `limit=500`). اگر تعداد پزشکان از ۵۰هزار بیشتر شود (محدودیت گوگل برای یک sitemap)، باید چندتایی شود — Next.js 15 از `generateSitemaps()` پشتیبانی میکند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Schema.org کامل برای صفحه پزشک
|
||||
|
||||
در `app/doctor/[slug]/page.js`، fetch آدرسهای مطب را اضافه کن (مشابه الگوی موجود `getDoctor`):
|
||||
|
||||
```js
|
||||
const getDoctorAddresses = cache(async (doctorId) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinic-pro/doctor-addresses/${doctorId}`, {
|
||||
next: { revalidate: 3600, tags: [`doctor-addresses-${doctorId}`] },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
return json?.data ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
سپس در کامپوننت `Doctor()`، schema را به این شکل بازنویسی کن:
|
||||
|
||||
```js
|
||||
const addresses = doctor ? await getDoctorAddresses(doctor.id) : [];
|
||||
|
||||
const jsonLd = doctor
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Physician",
|
||||
"@id": `https://www.nobat724.com/doctor/${doctor.uuid}#physician`,
|
||||
name: `دکتر ${doctor.name}`,
|
||||
url: `https://www.nobat724.com/doctor/${doctor.uuid}`,
|
||||
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
||||
...(doctor.img && {
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
url: imageUrl(doctor.img),
|
||||
},
|
||||
}),
|
||||
...(doctor.point && Number(doctor.point) > 0 && {
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
ratingValue: doctor.point,
|
||||
bestRating: "5",
|
||||
ratingCount: comments?.length || 1,
|
||||
},
|
||||
}),
|
||||
...(comments?.length > 0 && {
|
||||
review: comments.slice(0, 5).map((c) => ({
|
||||
"@type": "Review",
|
||||
author: { "@type": "Person", name: c.author || "بیمار" },
|
||||
reviewBody: c.text || c.body,
|
||||
...(c.rate && {
|
||||
reviewRating: { "@type": "Rating", ratingValue: c.rate, bestRating: "5" },
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
...(doctor.socialMedia && {
|
||||
sameAs: Object.values(doctor.socialMedia).filter(Boolean),
|
||||
}),
|
||||
...(addresses.length > 0 && {
|
||||
workLocation: addresses.map((addr) => ({
|
||||
"@type": "MedicalClinic",
|
||||
name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: addr.address,
|
||||
},
|
||||
...(addr.telephone && { telephone: addr.telephone }),
|
||||
...(addr.map?.latitude && addr.map?.longitude && {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: addr.map.latitude,
|
||||
longitude: addr.map.longitude,
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
```
|
||||
|
||||
**نکته مهم:** بررسی کن فیلد دقیق rate در `comments[]` (نام فیلد امتیاز هر نظر) چیست — در `docs/api/doctor.md` این جزئیات مشخص نشده، باید با `console.log` یا بررسی `docs/api/rating.md` (اگر در backend وجود دارد) دقیق کنی، فرضیات بالا (`c.rate`, `c.text`, `c.author`) را verify کن قبل از commit.
|
||||
|
||||
### ۲. Sitemap تفکیکشده
|
||||
|
||||
`app/sitemap.js` فعلی را به ساختار چندتایی Next.js 15 تبدیل کن. ساختار پیشنهادی:
|
||||
|
||||
```
|
||||
app/
|
||||
sitemap.js # index — فقط static pages (home, about, contact, specialties)
|
||||
doctor/sitemap.js # generateSitemaps() برای پزشکان، یکی به ازای هر 1000 پزشک
|
||||
clinic/sitemap.js # همین الگو برای کلینیکها
|
||||
blog/sitemap.js # همین الگو برای مقالات
|
||||
```
|
||||
|
||||
نمونه برای `app/doctor/sitemap.js` (الگوی رسمی Next.js 15 برای sitemap چندتایی):
|
||||
|
||||
```js
|
||||
export async function generateSitemaps() {
|
||||
// اگر تعداد پزشکان مشخص نیست، یک تخمین اولیه برگردان (مثلاً بر اساس count از API)
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/doctors?page=1&limit=1`, {
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
const json = await res.json();
|
||||
const total = json?.meta?.totalRecords || 0;
|
||||
const pageCount = Math.ceil(total / 1000);
|
||||
return Array.from({ length: pageCount }, (_, i) => ({ id: i }));
|
||||
}
|
||||
|
||||
export default async function sitemap({ id }) {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const res = await fetch(`${API_URL}/api/v1/doctors?page=${id + 1}&limit=1000`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const doctors = json?.data || [];
|
||||
return doctors
|
||||
.filter((d) => d?.uuid)
|
||||
.map((d) => ({
|
||||
url: `https://www.nobat724.com/doctor/${d.uuid}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
همین الگو را برای `clinic` و `blog` تکرار کن. `app/sitemap.js` اصلی فقط صفحات ایستا (`/`, `/about-us`, `/contact-us`, `/specialties`, `/doctors`, `/clinics`, `/blogs`) را برمیگرداند — منطق `getStaticPages()` موجود قابل reuse است، فقط `getDoctorUrls`/`getClinicUrls`/`getBlogUrls` از این فایل حذف و به فایلهای جدید منتقل میشوند.
|
||||
|
||||
**نکته مهم:** صفحات تخصص (`?specialty=`) و شهر (subdomain) در حال حاضر URL مجزا با path ندارند (فیلتر با query param است) — اضافهکردن اینها به sitemap بهصورت URL جدا (`/doctors?specialty=X`) ارزش SEO کمی دارد چون گوگل query-param URLها را با اولویت پایینتر میبیند. اگر میخواهی این بخش (URL Structure برای تخصص/شهر بهصورت path-based مثل `/doctors/tehran/cardiology`) را هم پیادهسازی کنی، این یک تغییر بزرگتر در routing است که باید جدا و با تأیید قبلی انجام شود — در این پرامپت فقط طراحی پیشنهادی را در بخش «نکات مهم» مستند کن، کد routing را عوض نکن مگر کاربر صریحاً بخواهد.
|
||||
|
||||
### ۳. بهروزرسانی `robots.js`
|
||||
|
||||
```js
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/dashboard', '/panel'],
|
||||
},
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
```
|
||||
|
||||
### ۴. کارت شبکههای اجتماعی در UI (مشروط به وجود فیلد backend)
|
||||
|
||||
در `components/doctor/detailDoctor/` (بررسی فایلهای موجود مثل `Share.js`) یک بخش کوچک برای نمایش آیکونهای شبکهاجتماعی پزشک اضافه کن، فقط اگر `doctor.socialMedia` مقدار غیر-null داشت:
|
||||
|
||||
```jsx
|
||||
{doctor?.socialMedia && Object.entries(doctor.socialMedia).some(([, v]) => v) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{doctor.socialMedia.instagram && (
|
||||
<a href={doctor.socialMedia.instagram} target="_blank" rel="noopener noreferrer">
|
||||
<InstagramIcon />
|
||||
</a>
|
||||
)}
|
||||
{/* تکرار برای telegram, aparat, youtube, linkedin */}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
آیکونهای لازم را در `components/icons/` بررسی کن — اگر آیکون اینستاگرام/تلگرام/آپارات/یوتیوب از قبل وجود ندارد، SVG ساده اضافه کن (الگوی فایلهای موجود در همان پوشه را دنبال کن).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **هیچ تغییری در `services/api.js`/`services/response.js` (مسیر axios سمت کلاینت) ندهی** مگر برای `socialMedia` که نیاز به submit از فرم ادمین دارد (آن فرم در `clinicpro` است، نه اینجا).
|
||||
- چون پروژه فعلاً مقاله/FAQ مرتبط به پزشک خاص ندارد، schemaهای `FAQPage` و `Article`/`BlogPosting` per-doctor را در این مرحله پیادهسازی نکن — فقط طراحی پیشنهادی (در صورت افزودن این قابلیت در آینده) را در پایان فایل توضیح بده، کد واقعی برایش ننویس.
|
||||
- صفحه `app/clinic/[slug]/page.js` میتواند schema مشابه (`MedicalClinic` با `aggregateRating`/`geo`) بگیرد — اگر وقت بود همان الگوی schema پزشک را برای کلینیک هم تکرار کن، اما اولویت اول صفحه پزشک است.
|
||||
- بعد از هر تغییر در `app/doctor/[slug]/page.js`، تست کن خروجی JSON-LD نهایی valid باشد — از Google Rich Results Test (بهصورت دستی، نه خودکار در این session) یا حداقل `JSON.parse(JSON.stringify(jsonLd))` بدون خطا.
|
||||
- پس از تغییرات، حتماً `npm run build` را اجرا کن — مسیرهای جدید sitemap (`app/doctor/sitemap.js` و غیره) باید بدون خطا کامپایل شوند و در خروجی build بهعنوان route مجزا دیده شوند.
|
||||
- اگر در حین اجرا متوجه شدی پرامپت backend (`doctor-social-media-field.md`) هنوز اجرا نشده، بخشهای `sameAs`/کارت شبکههای اجتماعی را با گارد `doctor?.socialMedia &&` (که در کد بالا هم رعایت شده) بدون خطا skip کن — منتظر آن پرامپت نمان.
|
||||
@@ -0,0 +1,49 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const MAIN_DOMAIN = 'https://nobat724.com';
|
||||
const PER_SITEMAP = 1000;
|
||||
|
||||
function toSafeDate(value) {
|
||||
if (!value) return new Date();
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
}
|
||||
|
||||
export async function generateSitemaps() {
|
||||
if (!API_URL) return [{ id: 0 }];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/blogs?page=1&limit=1`, {
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
if (!res.ok) return [{ id: 0 }];
|
||||
const json = await res.json();
|
||||
const total = json?.meta?.totalRecords || 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PER_SITEMAP));
|
||||
return Array.from({ length: pageCount }, (_, id) => ({ id }));
|
||||
} catch {
|
||||
return [{ id: 0 }];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap({ id }) {
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/blogs?page=${id + 1}&limit=${PER_SITEMAP}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const blogs = json?.data || [];
|
||||
return blogs
|
||||
.filter((b) => b?.slug || b?.uuid)
|
||||
.map((b) => ({
|
||||
url: `${MAIN_DOMAIN}/blog/${b.slug || b.uuid}`,
|
||||
lastModified: toSafeDate(b.created),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const MAIN_DOMAIN = 'https://nobat724.com';
|
||||
const PER_SITEMAP = 1000;
|
||||
|
||||
export async function generateSitemaps() {
|
||||
if (!API_URL) return [{ id: 0 }];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinics?page=1&limit=1`, {
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
if (!res.ok) return [{ id: 0 }];
|
||||
const json = await res.json();
|
||||
const total = json?.meta?.totalRecords || 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PER_SITEMAP));
|
||||
return Array.from({ length: pageCount }, (_, id) => ({ id }));
|
||||
} catch {
|
||||
return [{ id: 0 }];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap({ id }) {
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinics?page=${id + 1}&limit=${PER_SITEMAP}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const clinics = json?.data || [];
|
||||
return clinics
|
||||
.filter((c) => c?.uuid)
|
||||
.map((c) => ({
|
||||
url: `${MAIN_DOMAIN}/clinic/${c.uuid}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,19 @@ const getDoctor = cache(async (slug) => {
|
||||
}
|
||||
});
|
||||
|
||||
const getDoctorAddresses = cache(async (doctorId) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinic-pro/doctor-addresses/${doctorId}`, {
|
||||
next: { revalidate: 3600, tags: [`doctor-addresses-${doctorId}`] },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
return json?.data ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const { matchedCity } = await getStateInfo();
|
||||
@@ -79,20 +92,60 @@ async function Doctor({ params }) {
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const addresses = doctor ? await getDoctorAddresses(doctor.id) : [];
|
||||
|
||||
const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || "";
|
||||
const jsonLd = doctor
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Physician",
|
||||
"@id": `https://www.nobat724.com/doctor/${doctor.uuid}#physician`,
|
||||
name: `دکتر ${doctor.name}`,
|
||||
url: `https://www.nobat724.com/doctor/${doctor.uuid}`,
|
||||
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
||||
...(doctor.img && { image: doctor.img }),
|
||||
...(doctor.address && {
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: doctor.address,
|
||||
...(doctor.img && {
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
url: imageUrl(doctor.img),
|
||||
},
|
||||
}),
|
||||
...(Number(doctor.point) > 0 && {
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
ratingValue: doctor.point,
|
||||
bestRating: "5",
|
||||
ratingCount: comments?.length || 1,
|
||||
},
|
||||
}),
|
||||
...(comments?.length > 0 && {
|
||||
review: comments.slice(0, 5).map((c) => ({
|
||||
"@type": "Review",
|
||||
author: { "@type": "Person", name: c.author?.real_name || "بیمار" },
|
||||
reviewBody: c.comment,
|
||||
datePublished: new Date(c.created * 1000).toISOString(),
|
||||
})),
|
||||
}),
|
||||
...(doctor.social_media && {
|
||||
sameAs: Object.values(doctor.social_media).filter(Boolean),
|
||||
}),
|
||||
...(addresses.length > 0 && {
|
||||
workLocation: addresses.map((addr) => ({
|
||||
"@type": "MedicalClinic",
|
||||
name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: addr.address,
|
||||
},
|
||||
...(addr.telephone && { telephone: addr.telephone }),
|
||||
...(addr.map?.latitude && addr.map?.longitude && {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: addr.map.latitude,
|
||||
longitude: addr.map.longitude,
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const MAIN_DOMAIN = 'https://nobat724.com';
|
||||
const PER_SITEMAP = 1000;
|
||||
|
||||
export async function generateSitemaps() {
|
||||
if (!API_URL) return [{ id: 0 }];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctors?page=1&limit=1`, {
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
if (!res.ok) return [{ id: 0 }];
|
||||
const json = await res.json();
|
||||
const total = json?.meta?.totalRecords || 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PER_SITEMAP));
|
||||
return Array.from({ length: pageCount }, (_, id) => ({ id }));
|
||||
} catch {
|
||||
return [{ id: 0 }];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap({ id }) {
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctors?page=${id + 1}&limit=${PER_SITEMAP}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const doctors = json?.data || [];
|
||||
return doctors
|
||||
.filter((d) => d?.uuid)
|
||||
.map((d) => ({
|
||||
url: `${MAIN_DOMAIN}/doctor/${d.uuid}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ export default function robots() {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/dashboard',
|
||||
disallow: ['/dashboard', '/panel'],
|
||||
},
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
|
||||
+1
-91
@@ -1,12 +1,6 @@
|
||||
import { headers } from 'next/headers';
|
||||
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() {
|
||||
@@ -33,95 +27,11 @@ function getStaticPages(baseUrl) {
|
||||
];
|
||||
}
|
||||
|
||||
async function getDoctorUrls(baseUrl) {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctors?page=1&limit=500`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const doctors = data?.data || data?.doctors || data || [];
|
||||
return doctors
|
||||
.filter((d) => d?.uuid)
|
||||
.map((d) => ({
|
||||
url: `${baseUrl}/doctor/${d.uuid}`,
|
||||
lastModified: NOW,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getClinicUrls(baseUrl) {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinics?page=1&limit=500`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const clinics = data?.data || data?.clinics || data || [];
|
||||
return clinics
|
||||
.filter((c) => c?.uuid)
|
||||
.map((c) => ({
|
||||
url: `${baseUrl}/clinic/${c.uuid}`,
|
||||
lastModified: NOW,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getBlogUrls(baseUrl) {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!API_URL) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/blogs?page=1&limit=200`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const blogs = data?.blogs || data?.data || data || [];
|
||||
return blogs
|
||||
.filter((b) => b?.slug || b?.uuid)
|
||||
.map((b) => ({
|
||||
url: `${baseUrl}/blog/${b.slug || b.uuid}`,
|
||||
lastModified: toSafeDate(b.created),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap() {
|
||||
try {
|
||||
const domain = getCurrentDomain();
|
||||
const baseUrl = getBaseUrl(domain);
|
||||
|
||||
const [staticPages, doctorUrls, clinicUrls, blogUrls] = await Promise.all([
|
||||
Promise.resolve(getStaticPages(baseUrl)),
|
||||
getDoctorUrls(baseUrl),
|
||||
getClinicUrls(baseUrl),
|
||||
getBlogUrls(baseUrl),
|
||||
]);
|
||||
|
||||
const allUrls = [...staticPages, ...doctorUrls, ...clinicUrls, ...blogUrls];
|
||||
|
||||
return allUrls.filter(
|
||||
(item, index, arr) => arr.findIndex((i) => i.url === item.url) === index
|
||||
);
|
||||
return getStaticPages(baseUrl);
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
return [];
|
||||
|
||||
@@ -5,6 +5,19 @@ import LikeDI from "@/components/icons/LikeDI";
|
||||
import StarDI from "@/components/icons/StarDI";
|
||||
import TickCircle from "@/components/icons/TickCircle";
|
||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||
import Instagram from "@/components/icons/Instagram";
|
||||
import Telegram from "@/components/icons/Telegram";
|
||||
import Aparat from "@/components/icons/Aparat";
|
||||
import Youtube from "@/components/icons/Youtube";
|
||||
import Linkedin from "@/components/icons/Linkedin";
|
||||
|
||||
const SOCIAL_ICONS = {
|
||||
instagram: Instagram,
|
||||
telegram: Telegram,
|
||||
aparat: Aparat,
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
};
|
||||
|
||||
function Title({ doctor }) {
|
||||
return (
|
||||
@@ -67,6 +80,26 @@ function Title({ doctor }) {
|
||||
</div>
|
||||
</div>
|
||||
</CustomLoading>
|
||||
{doctor?.social_media && Object.values(doctor.social_media).some(Boolean) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{Object.entries(doctor.social_media).map(([key, url]) => {
|
||||
if (!url) return null;
|
||||
const Icon = SOCIAL_ICONS[key];
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1.5 rounded-full bg-[#F8F8FF]"
|
||||
>
|
||||
<Icon />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
function Aparat() {
|
||||
return (
|
||||
<svg
|
||||
className="transition-all duration-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M18.3334 10.0001C18.3334 14.6025 14.6025 18.3334 10.0001 18.3334C5.39771 18.3334 1.66675 14.6025 1.66675 10.0001C1.66675 5.39771 5.39771 1.66675 10.0001 1.66675C14.6025 1.66675 18.3334 5.39771 18.3334 10.0001Z"
|
||||
stroke="#5559CE"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.33325 7.08331L12.9166 9.99998L8.33325 12.9166V7.08331Z"
|
||||
stroke="#5559CE"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Aparat;
|
||||
@@ -0,0 +1,27 @@
|
||||
function Youtube() {
|
||||
return (
|
||||
<svg
|
||||
className="transition-all duration-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1.66675 10.0001C1.66675 6.27106 1.66675 4.40656 2.78731 3.18996C3.90787 1.97337 5.66881 1.97337 9.19069 1.97337H10.8092C14.331 1.97337 16.092 1.97337 17.2125 3.18996C18.3331 4.40656 18.3334 6.27106 18.3334 10.0001C18.3334 13.7291 18.3334 15.5936 17.2125 16.8102C16.092 18.0268 14.331 18.0268 10.8092 18.0268H9.19069C5.66881 18.0268 3.90787 18.0268 2.78731 16.8102C1.66675 15.5936 1.66675 13.7291 1.66675 10.0001Z"
|
||||
stroke="#5559CE"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.33325 7.50002L12.5 10.0001L8.33325 12.5V7.50002Z"
|
||||
stroke="#5559CE"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Youtube;
|
||||
Reference in New Issue
Block a user