diff --git a/.claude/prompt/seo-performance-rendering-audit.md b/.claude/prompt/seo-performance-rendering-audit.md new file mode 100644 index 0000000..20bc493 --- /dev/null +++ b/.claude/prompt/seo-performance-rendering-audit.md @@ -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 ( +
+

مشکلی پیش آمد. لطفاً دوباره تلاش کنید.

+ +
+ ); +} +``` + +### ۸. بررسی `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 باشند، مطابق بقیه‌ی پروژه. diff --git a/app/about-us/page.js b/app/about-us/page.js index 89fa48a..763e2d8 100644 --- a/app/about-us/page.js +++ b/app/about-us/page.js @@ -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] }, }; } diff --git a/app/blog/[slug]/loading.js b/app/blog/[slug]/loading.js new file mode 100644 index 0000000..0372100 --- /dev/null +++ b/app/blog/[slug]/loading.js @@ -0,0 +1,13 @@ +import { Skeleton } from "@mui/material"; + +export default function Loading() { + return ( +
+ + + + + +
+ ); +} diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 57145d9..256f44f 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -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 && ( +