feat(metadata): enhance SEO by adding Open Graph and Twitter metadata across various pages
feat(blog): implement loading and error handling components for blog pages feat(clinic): add loading and error handling components for clinic pages feat(doctor): create loading and error handling components for doctor pages feat(clinics): add loading component for clinics page feat(specialties): improve metadata for specialties page with Open Graph and Twitter images feat(layout): add structured data for Organization and WebSite in layout fix(middleware): restrict middleware execution to specific routes to improve performance chore(audit): add comprehensive SEO and performance audit documentation
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# بازبینی کامل Rendering Strategy، SEO، Performance و امنیت (Next.js 15)
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
## زمینه
|
||||
|
||||
این سایت عمومی نوبتدهی (App Router، چند-شهری، RTL فارسی) چندین مشکل ساختاری دارد که هم روی SEO و هم روی performance اثر میگذارد:
|
||||
|
||||
1. تمام fetchهای server-side با **axios** انجام میشوند (`lib/req.js` → `fetchReq`/`axiosInstance`)، نه `fetch` بومی Next.js. این یعنی **هیچکدام از قابلیتهای `cache`/`revalidate`/`force-cache`/`no-store` Next.js کار نمیکنند** — همهچیز عملاً همیشه SSR بدون cache است، حتی صفحاتی که میتوانند ISR باشند (مثل `/doctor/[slug]`).
|
||||
2. `middleware.js` روی همه مسیرها (`matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'`) اجرا میشود تا فقط یک هدر (`x-pathname`) ست کند — این کار رندر استاتیک را در سطح کل سایت بهصورت اجباری به dynamic تبدیل میکند.
|
||||
3. در `app/doctor/[slug]/page.js`، `generateMetadata` و کامپوننت `Doctor()` هر دو مستقل `GET /api/v1/doctor/${slug}` را صدا میزنند — یعنی هر بار بازدید صفحه پزشک، **۲ بار درخواست یکسان** به backend میرود (هیچ dedup با `React.cache()` وجود ندارد چون axios است نه fetch).
|
||||
4. هیچ `loading.js`, `error.js`, `template.js` در کل `app/` وجود ندارد — یعنی هیچ Suspense boundary یا error boundary واقعی در سطح route نیست؛ خطاهای fetch با `catch` خاموش میشوند و صفحه با داده `null` رندر میشود.
|
||||
5. `og:image`/`twitter:image` در بسیاری صفحات به یک لوگوی استاتیک ثابت (`https://www.nobat724.com/assets/images/logo.png`) فالبک میکنند یا اصلاً ست نمیشوند (`app/doctors/page.js` فقط `title`/`description` در `openGraph` دارد، بدون `images`).
|
||||
6. JSON-LD فقط در `doctor/[slug]`، `clinic/[slug]`، `blog/[slug]` هست؛ هیچ `Organization`, `WebSite`, `BreadcrumbList` در سطح global (`layout.js`) وجود ندارد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `lib/req.js` | `fetchReq`/`axiosInstance` — تمام server fetchها از اینجا رد میشوند |
|
||||
| `middleware.js` | روی همه مسیرها اجرا میشود، رندر دینامیک سراسری تحمیل میکند |
|
||||
| `app/layout.js` | `generateMetadata` ریشه؛ بدون JSON-LD سراسری (Organization/WebSite) |
|
||||
| `app/doctors/page.js` | لیست پزشکان؛ SSR کامل، بدون cache/revalidate، بدون `og:images` |
|
||||
| `app/doctor/[slug]/page.js` | دو فراخوانی تکراری به همان endpoint؛ JSON-LD ناقص (بدون `@id`, `url`) |
|
||||
| `app/clinic/[slug]/page.js`, `app/blog/[slug]/page.js` | الگوی مشابه `doctor/[slug]` — باید با همان منطق بررسی شوند |
|
||||
| `app/specialties/page.js`, `app/about-us/page.js`, `app/blogs/page.js`, `app/clinics/page.js` | از `getStateInfo()` برای متادیتا استفاده میکنند؛ محتوای نسبتاً ایستا اما بهصورت SSR رندر میشوند |
|
||||
| `app/dashboard/page.js`, `app/panel/(layout)/layout.js` | پنل کاربری احرازشده — اینها باید SSR/CSR بمانند (داده per-user) |
|
||||
| `lib/getStateInfo.js` | تشخیص شهر از subdomain — روی هر درخواست header میخواند، نمیتواند cache شود مگر با segment config درست |
|
||||
| `app/sitemap.js`, `app/robots.js` | باید بررسی شوند که `revalidate` و فیلتر صفحات DEV_MODE درست تنظیم شده باشد |
|
||||
| `app/globals.css`, `mui/index.js` | فونت Vazir، بررسی `next/font` بهجای `@font-face` دستی |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### `lib/req.js` — مشکل اصلی caching
|
||||
|
||||
```js
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
export const axiosInstance = axios.create({
|
||||
...(process.env.NODE_ENV === "development" && {
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const fetchReq = async (url, headers) => {
|
||||
try {
|
||||
const response = await axiosInstance.get(url, headers);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("fetchReq error:", error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### `app/doctor/[slug]/page.js` — دو فراخوانی تکراری
|
||||
|
||||
```js
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
// ...
|
||||
const res = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`);
|
||||
// ...
|
||||
}
|
||||
|
||||
async function Doctor({ params }) {
|
||||
const { slug } = await params;
|
||||
// ...
|
||||
const resDoctor = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`); // همان درخواست، دوباره
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### `middleware.js` — اجرا روی همه مسیرها
|
||||
|
||||
```js
|
||||
export function middleware(request) {
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('x-pathname', request.nextUrl.pathname);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
```
|
||||
|
||||
### `app/doctors/page.js` — بدون og:images، بدون cache
|
||||
|
||||
```js
|
||||
export async function generateMetadata() {
|
||||
// ...
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description }, // بدون images
|
||||
};
|
||||
}
|
||||
|
||||
async function Doctors({ searchParams }) {
|
||||
// ...
|
||||
doctors = await fetchReq(`${API_URL}/api/v1/doctors`, { params }); // هر بار fresh، بدون revalidate
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. جایگزینی fetch لایهی axios با `fetch` بومی Next.js (یا wrapper روی آن) برای فراخوانیهای Server Component
|
||||
|
||||
برای صفحاتی که قابل ISR هستند (`doctor/[slug]`, `clinic/[slug]`, `blog/[slug]`, `specialties`, `about-us`)، بهجای `axiosInstance.get`/`fetchReq` از `fetch` با `next.revalidate` استفاده کن:
|
||||
|
||||
```js
|
||||
async function getDoctor(slug) {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
||||
next: { revalidate: 3600, tags: [`doctor-${slug}`] },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.data?.data;
|
||||
}
|
||||
```
|
||||
|
||||
- برای صفحاتی که داده per-user/per-session دارند (`dashboard`, `panel/*`, `appointment/[doctorId]` در حالت لاگینشده) از `cache: 'no-store'` یا اصلاً تغییری در رویکرد SSR فعلی نده.
|
||||
- چون مسیر axios هنوز برای client-side (`services/api.js`/`services/response.js`) لازم است، **این تغییر را فقط در فایلهای Server Component (`app/.../page.js`) اعمال کن** — axios در client services دست نخورد.
|
||||
|
||||
### ۲. حذف فراخوانی تکراری در `doctor/[slug]/page.js` (و الگوی مشابه در `clinic/[slug]`, `blog/[slug]`)
|
||||
|
||||
از `React.cache()` برای dedup بین `generateMetadata` و کامپوننت صفحه استفاده کن:
|
||||
|
||||
```js
|
||||
import { cache } from "react";
|
||||
|
||||
const getDoctor = cache(async (slug) => {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.data?.data ?? null;
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const doctor = await getDoctor(slug);
|
||||
// ...
|
||||
}
|
||||
|
||||
async function Doctor({ params }) {
|
||||
const { slug } = await params;
|
||||
const doctor = await getDoctor(slug); // همان نتیجه cacheشده، بدون درخواست دوم
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
بررسی کن همین الگو در `app/clinic/[slug]/page.js` و `app/blog/[slug]/page.js` هم تکرار شده یا نه و در صورت وجود اصلاح کن.
|
||||
|
||||
### ۳. بازبینی `middleware.js` — محدود کردن matcher یا حذف وابستگی غیرضروری
|
||||
|
||||
اگر `x-pathname` فقط برای `getCanonicalUrl()` لازم است، بررسی کن آیا میتوان canonical را بدون middleware (مثلاً از `headers()` در خود `generateMetadata` با `request.url` معادل App Router، یا با محاسبه از `params`/segment) ساخت. اگر middleware واقعاً لازم است، **matcher را به مسیرهایی که واقعاً به canonical نیاز دارند محدود کن** (نه همهی سایت):
|
||||
|
||||
```js
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/doctor/:path*',
|
||||
'/clinic/:path*',
|
||||
'/blog/:path*',
|
||||
'/doctors',
|
||||
'/clinics',
|
||||
'/blogs',
|
||||
'/specialties',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
مستندسازی کن که این تغییر چه صفحاتی را از حالت force-dynamic خارج میکند.
|
||||
|
||||
### ۴. افزودن JSON-LD سراسری در `app/layout.js`
|
||||
|
||||
`Organization` و `WebSite` schema را یکبار در ریشه اضافه کن (نه در هر صفحه):
|
||||
|
||||
```jsx
|
||||
const orgJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: matchedCity?.site_name || "نوبت 724",
|
||||
url: "https://www.nobat724.com",
|
||||
logo: "https://www.nobat724.com/assets/images/logo.png",
|
||||
};
|
||||
const websiteJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
url: "https://www.nobat724.com",
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: "https://www.nobat724.com/doctors?search={search_term_string}",
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
و `BreadcrumbList` در صفحات تکآیتمی (`doctor/[slug]`, `clinic/[slug]`, `blog/[slug]`) کنار JSON-LD موجود اضافه کن:
|
||||
|
||||
```js
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "پزشکان", item: "https://www.nobat724.com/doctors" },
|
||||
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### ۵. تکمیل og:image/twitter:image در همه صفحات
|
||||
|
||||
- `app/doctors/page.js`: اضافه کن `images: ["https://www.nobat724.com/assets/images/logo.png"]` (یا تصویر مرتبطتر اگر موجود است) به `openGraph` و `twitter`.
|
||||
- `app/doctor/[slug]/page.js`: مقدار `doctor.img` را قبل از استفاده در `images` با `imageUrl()` (از `helper/index.js`) absolute کن — همان helper که در کار قبلی avatar استفاده شد — چون ممکن است relative path باشد و در og:image کرول نشود:
|
||||
|
||||
```js
|
||||
import { imageUrl } from "@/helper";
|
||||
// ...
|
||||
images: [imageUrl(doctor.img) || "https://www.nobat724.com/assets/images/logo.png"],
|
||||
```
|
||||
|
||||
- همین بررسی را برای `app/clinic/[slug]/page.js` و `app/blog/[slug]/page.js` انجام بده.
|
||||
|
||||
### ۶. اضافه کردن `loading.js` برای مسیرهای دادهمحور
|
||||
|
||||
برای `app/doctors/`, `app/doctor/[slug]/`, `app/clinics/`, `app/clinic/[slug]/`, `app/blogs/`, `app/blog/[slug]/` یک `loading.js` با اسکلت متناسب با `CircularLoading`/`TextLoading`/`CustomLoading` موجود در `app/component/loading/` بساز (این کامپوننتها همین الان هم بهصورت دستی در صفحات استفاده میشوند؛ هدف اینجا یک Suspense boundary واقعی در سطح route است، نه تغییر کامپوننتهای فعلی).
|
||||
|
||||
### ۷. اضافه کردن `error.js` در سطح root و برای مسیرهای پرتقاضا
|
||||
|
||||
یک `app/error.js` (Client Component با `"use client"`) برای گرفتن خطاهای رندر، و یک `error.js` در `app/doctor/[slug]/` برای حالتی که fetch واقعاً fail میکند (بهجای برگرداندن `null` خاموش):
|
||||
|
||||
```jsx
|
||||
"use client";
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="p-8 text-center" dir="rtl">
|
||||
<p>مشکلی پیش آمد. لطفاً دوباره تلاش کنید.</p>
|
||||
<button onClick={() => reset()}>تلاش دوباره</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ۸. بررسی `app/sitemap.js` و `app/robots.js`
|
||||
|
||||
تأیید کن:
|
||||
- `sitemap.js` از همان axios/fetchReq استفاده نمیکند بدون cache (احتمال timeout زیر بار)؛ در صورت لزوم به fetch بومی با `revalidate` بزرگ (مثلاً ۲۴ ساعت) تغییر بده.
|
||||
- صفحات `panel/*` و `dashboard` در sitemap نباشند (نیاز auth دارند).
|
||||
- `robots.js` در حالت `DEV_MODE=TRUE` همهچیز را drop میکند (طبق `CLAUDE.md` همین الان این رفتار مستند است) — فقط تأیید کن پیادهسازی با مستندات همخوان است.
|
||||
|
||||
### ۹. گزارش نهایی بهصورت جدول
|
||||
|
||||
برای هر صفحهی زیر جدول را تکمیل کن — این لیست کامل صفحات `app/` پروژه است (تمام موارد را پوشش بده، هیچکدام را رد نکن):
|
||||
|
||||
`/`, `/about-us`, `/contact-us`, `/specialties`, `/blogs`, `/blog/[slug]`, `/clinics`, `/clinic/[slug]`, `/doctors`, `/doctor/[slug]`, `/appointment/[doctorId]`, `/login`, `/login-verify`, `/dashboard`, `/panel/add-doctor`, `/panel/dashboard` (route group), `/panel/turns`, `/panel/user-account`, `/payment/[uuid]`, `/payment/result`
|
||||
|
||||
| Page | Current Strategy | Recommended Strategy | Reason | SEO Impact | Performance Impact |
|
||||
|------|------------------|----------------------|--------|-------------|---------------------|
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **هیچ تغییری در `services/api.js`/`services/response.js` (مسیر axios سمت کلاینت) ندهی** — فقط فراخوانیهای Server Component (`app/.../page.js`) که با `axiosInstance`/`fetchReq` کار میکنند هدف این پرامپت هستند.
|
||||
- صفحات `panel/*` و `dashboard` چون نیاز به session/JWT کاربر دارند و داده per-user است، **باید SSR/dynamic بمانند** — این صفحات را به ISR/SSG تبدیل نکن؛ فقط در گزارش جدول توضیح بده چرا.
|
||||
- `getStateInfo()` به `host` header وابسته است (تشخیص subdomain چندشهری) — این یعنی صفحاتی که از آن استفاده میکنند (`generateMetadata` همه صفحات public) را نمیتوان بهطور کامل static کرد مگر با `generateStaticParams` محدود به دامنههای شناختهشده در `data/city.json`؛ اگر چنین تغییری پیشنهاد میشود، توضیح بده trade-off چندشهری بودن چیست.
|
||||
- پس از هر تغییر در `app/.../page.js`، طبق قانون پروژه (`CLAUDE.md`): «همیشه `await params`» را رعایت کن — این الگو همین الان در همه فایلها هست، نشکن.
|
||||
- بعد از تغییرات، حتماً `npm run build` را اجرا کن و خروجی Route را بررسی کن — ستون `Size`/`First Load JS` باید تغییر معنادار (کاهش یا حداقل عدم افزایش) داشته باشد.
|
||||
- `npm run lint` در این پروژه به دلیل عدم migrate شدن از `next lint` به ESLint CLI، interactive میپرسد و کار نمیکند (مشکل از قبل موجود، نه نتیجه این تغییرات) — برای validation فقط به `npm run build` تکیه کن.
|
||||
- تمام متنهای جدید (پیام خطا، loading text و غیره) باید فارسی و RTL باشند، مطابق بقیهی پروژه.
|
||||
@@ -7,10 +7,12 @@ export async function generateMetadata() {
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
const title = `درباره ما | ${siteName}`;
|
||||
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
|
||||
const image = "https://www.nobat724.com/assets/images/logo.png";
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4" dir="rtl">
|
||||
<Skeleton variant="rounded" height={280} className="!rounded-lg" />
|
||||
<Skeleton variant="rounded" width={220} height={28} />
|
||||
<Skeleton variant="rounded" height={20} className="!w-full" />
|
||||
<Skeleton variant="rounded" height={20} className="!w-full" />
|
||||
<Skeleton variant="rounded" height={20} width={300} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
-6
@@ -1,3 +1,4 @@
|
||||
import { cache } from "react";
|
||||
import BlogPage from "@/components/blog";
|
||||
import Layout from "@/components/layout/StLayout";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
@@ -5,16 +6,28 @@ import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { normalizeBlog, imageUrl } from "@/helper";
|
||||
|
||||
const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png";
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const getBlog = cache(async (slug) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/blog/${slug}`, {
|
||||
next: { revalidate: 3600, tags: [`blog-${slug}`] },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.data?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
|
||||
try {
|
||||
const res = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
||||
const blog = res?.data?.data;
|
||||
const blog = await getBlog(slug);
|
||||
if (!blog) return {};
|
||||
|
||||
const title = `${blog.title} | ${siteName}`;
|
||||
@@ -50,10 +63,8 @@ export async function generateMetadata({ params }) {
|
||||
|
||||
async function Blog({ params }) {
|
||||
const { slug } = await params;
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const res = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
||||
const blog = normalizeBlog(res?.data?.data);
|
||||
const blog = normalizeBlog(await getBlog(slug));
|
||||
|
||||
let relatedBlogs = [];
|
||||
const relatedResponse = await fetchReq(
|
||||
@@ -77,6 +88,18 @@ async function Blog({ params }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const breadcrumbJsonLd = blog
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "مقالات", item: "https://www.nobat724.com/blogs" },
|
||||
{ "@type": "ListItem", position: 3, name: blog.title },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLd && (
|
||||
@@ -85,6 +108,12 @@ async function Blog({ params }) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<BlogPage blog={blog} blogs={relatedBlogs} />
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4" dir="rtl">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} variant="rounded" height={220} className="!rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -7,10 +7,12 @@ export async function generateMetadata() {
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
const title = `مقالات و اخبار پزشکی | ${siteName}`;
|
||||
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
|
||||
const image = "https://www.nobat724.com/assets/images/logo.png";
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-6" dir="rtl">
|
||||
<Skeleton variant="rounded" height={180} className="!rounded-lg" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton variant="rounded" width={200} height={24} />
|
||||
<Skeleton variant="rounded" width={160} height={20} />
|
||||
</div>
|
||||
<Skeleton variant="rounded" height={300} className="!rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { cache } from "react";
|
||||
import ClinicPage from "@/components/clinic";
|
||||
import Layout from "@/components/layout/StLayout";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { imageUrl } from "@/helper";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const getClinic = cache(async (slug) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/clinic/${slug}`, {
|
||||
next: { revalidate: 3600, tags: [`clinic-${slug}`] },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.data?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
|
||||
try {
|
||||
const res = await fetchReq(`${API_URL}/api/v1/clinic/${slug}`);
|
||||
const clinic = res?.data?.data;
|
||||
const clinic = await getClinic(slug);
|
||||
if (!clinic) return {};
|
||||
|
||||
const title = `${clinic.title} | ${siteName}`;
|
||||
@@ -37,16 +51,14 @@ export async function generateMetadata({ params }) {
|
||||
async function Clinic({ params, searchParams }) {
|
||||
const { slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const page = Number(sp?.page) || 1;
|
||||
const limit = 50;
|
||||
|
||||
const reqClinics = await fetchReq(`${API_URL}/api/v1/clinic/${slug}`);
|
||||
const clinic = await getClinic(slug);
|
||||
const reqDoctors = await fetchReq(
|
||||
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
|
||||
);
|
||||
|
||||
const clinic = reqClinics?.data?.data || null;
|
||||
const doctors = reqDoctors?.data?.data || [];
|
||||
const meta = reqDoctors?.data?.meta;
|
||||
const pagedoctors = meta
|
||||
@@ -64,6 +76,18 @@ async function Clinic({ params, searchParams }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const breadcrumbJsonLd = clinic
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "کلینیکها", item: "https://www.nobat724.com/clinics" },
|
||||
{ "@type": "ListItem", position: 3, name: clinic.title },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLd && (
|
||||
@@ -72,6 +96,12 @@ async function Clinic({ params, searchParams }) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<ClinicPage data={clinic} doctors={doctors} slug={slug} pages={pagedoctors} />
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4" dir="rtl">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} variant="rounded" height={140} className="!rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -14,10 +14,12 @@ export async function generateMetadata() {
|
||||
const description = cityName
|
||||
? `لیست کلینیکها و مراکز درمانی در ${cityName}. رزرو آنلاین نوبت از بهترین مراکز درمانی ${cityName}.`
|
||||
: `جستجوی کلینیکها و مراکز درمانی در سراسر کشور. رزرو آنلاین نوبت سریع و آسان.`;
|
||||
const image = "https://www.nobat724.com/assets/images/logo.png";
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 min-h-[60vh] p-8 text-center"
|
||||
dir="rtl"
|
||||
>
|
||||
<p className="text-[#3B3B3B] text-[16px] font-bold">
|
||||
اطلاعات این پزشک در حال حاضر در دسترس نیست.
|
||||
</p>
|
||||
<p className="text-[#616161] text-[14px]">لطفاً دوباره تلاش کنید.</p>
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="px-6 py-2 rounded-full bg-[#3B3B3B] text-white text-[14px] font-medium"
|
||||
>
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-6" dir="rtl">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-4">
|
||||
<Skeleton variant="circular" width={164} height={164} />
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Skeleton variant="rounded" width={200} height={24} />
|
||||
<Skeleton variant="rounded" width={160} height={20} />
|
||||
<Skeleton variant="rounded" width={120} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton variant="rounded" height={300} className="!rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+54
-18
@@ -1,17 +1,32 @@
|
||||
import { cache } from "react";
|
||||
import DoctorPage from "@/components/doctor";
|
||||
import Layout from "@/components/layout/StLayout";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { axiosInstance } from "@/lib/req";
|
||||
import { imageUrl } from "@/helper";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png";
|
||||
|
||||
const getDoctor = cache(async (slug) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
||||
next: { revalidate: 3600, tags: [`doctor-${slug}`] },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.data?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`);
|
||||
const doctor = res.data?.data?.data;
|
||||
const doctor = await getDoctor(slug);
|
||||
if (!doctor) return {};
|
||||
|
||||
const specialtyNames = doctor.specialties?.map((s) => s.name).join(" و ") || "";
|
||||
@@ -19,6 +34,7 @@ export async function generateMetadata({ params }) {
|
||||
const description =
|
||||
doctor.detail ||
|
||||
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim();
|
||||
const image = imageUrl(doctor.img) || FALLBACK_IMG;
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -27,13 +43,13 @@ export async function generateMetadata({ params }) {
|
||||
title,
|
||||
description,
|
||||
type: "profile",
|
||||
images: doctor.img ? [doctor.img] : ["https://www.nobat724.com/assets/images/logo.png"],
|
||||
images: [image],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title,
|
||||
description,
|
||||
images: doctor.img ? [doctor.img] : ["https://www.nobat724.com/assets/images/logo.png"],
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -43,23 +59,25 @@ export async function generateMetadata({ params }) {
|
||||
|
||||
async function Doctor({ params }) {
|
||||
const { slug } = await params;
|
||||
let doctor = null;
|
||||
let comments = null;
|
||||
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
try {
|
||||
const resDoctor = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`);
|
||||
doctor = resDoctor.data?.data?.data;
|
||||
if (doctor) {
|
||||
const doctor = await getDoctor(slug);
|
||||
|
||||
if (doctor) {
|
||||
try {
|
||||
const [resComments, resRate] = await Promise.all([
|
||||
axiosInstance.get(`${API_URL}/api/v1/comments/${doctor.uuid}`),
|
||||
axiosInstance.get(`${API_URL}/api/v1/rate/${doctor.uuid}`),
|
||||
fetch(`${API_URL}/api/v1/comments/${doctor.uuid}`, { cache: "no-store" }),
|
||||
fetch(`${API_URL}/api/v1/rate/${doctor.uuid}`, { cache: "no-store" }),
|
||||
]);
|
||||
comments = resComments?.data?.data?.data;
|
||||
rateAggregate = resRate?.data?.data?.data ?? rateAggregate;
|
||||
}
|
||||
} catch (error) {}
|
||||
const [jsonComments, jsonRate] = await Promise.all([
|
||||
resComments.ok ? resComments.json() : null,
|
||||
resRate.ok ? resRate.json() : null,
|
||||
]);
|
||||
comments = jsonComments?.data?.data;
|
||||
rateAggregate = jsonRate?.data?.data ?? rateAggregate;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || "";
|
||||
const jsonLd = doctor
|
||||
@@ -78,6 +96,18 @@ async function Doctor({ params }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const breadcrumbJsonLd = doctor
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "پزشکان", item: "https://www.nobat724.com/doctors" },
|
||||
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLd && (
|
||||
@@ -86,6 +116,12 @@ async function Doctor({ params }) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<DoctorPage
|
||||
doctor={doctor}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4" dir="rtl">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<Skeleton key={idx} variant="rounded" height={140} className="!rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -14,10 +14,12 @@ export async function generateMetadata() {
|
||||
const description = cityName
|
||||
? `لیست پزشکان متخصص در ${cityName}. جستجو بر اساس تخصص و منطقه. رزرو آنلاین نوبت پزشکی در ${cityName}.`
|
||||
: `جستجوی پزشکان متخصص در سراسر کشور. رزرو آنلاین نوبت پزشکی سریع و آسان با نوبت 724.`;
|
||||
const image = "https://www.nobat724.com/assets/images/logo.png";
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 min-h-[60vh] p-8 text-center"
|
||||
dir="rtl"
|
||||
>
|
||||
<p className="text-[#3B3B3B] text-[16px] font-bold">مشکلی پیش آمد.</p>
|
||||
<p className="text-[#616161] text-[14px]">لطفاً دوباره تلاش کنید.</p>
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="px-6 py-2 rounded-full bg-[#3B3B3B] text-white text-[14px] font-medium"
|
||||
>
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+31
-1
@@ -66,8 +66,30 @@ export async function generateMetadata() {
|
||||
return baseMetadata;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
export default async function RootLayout({ children }) {
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
const siteUrl = "https://www.nobat724.com";
|
||||
|
||||
const organizationJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/assets/images/logo.png`,
|
||||
};
|
||||
|
||||
const websiteJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
url: siteUrl,
|
||||
name: siteName,
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: `${siteUrl}/doctors?search={search_term_string}`,
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
@@ -76,6 +98,14 @@ export default function RootLayout({ children }) {
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1"
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<ThemeRegistry>
|
||||
{process.env.DEV_MODE === 'FALSE' && (
|
||||
|
||||
@@ -10,10 +10,12 @@ export async function generateMetadata() {
|
||||
? `تخصصهای پزشکی در ${cityName} | ${siteName}`
|
||||
: `تخصصهای پزشکی | ${siteName}`;
|
||||
const description = `لیست کامل تخصصهای پزشکی${cityName ? " در " + cityName : ""}. رزرو نوبت از متخصصین مختلف به صورت آنلاین.`;
|
||||
const image = "https://www.nobat724.com/assets/images/logo.png";
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ export const config = {
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - panel, dashboard, login, login-verify (auth-gated/noindex pages — canonical URL has no SEO value here)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|panel|dashboard|login|login-verify).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user