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:
hamed
2026-06-21 11:38:39 +03:30
parent 6ec2f5a069
commit 06a877a725
19 changed files with 566 additions and 37 deletions
+3 -1
View File
@@ -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] },
};
}
+13
View File
@@ -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
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -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] },
};
}
+14
View File
@@ -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>
);
}
+36 -6
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -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] },
};
}
+21
View File
@@ -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>
);
}
+17
View File
@@ -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
View File
@@ -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}
+11
View File
@@ -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
View File
@@ -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] },
};
}
+19
View File
@@ -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
View File
@@ -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' && (
+3 -1
View File
@@ -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] },
};
}