Refactor blog components for improved tag filtering and related content display

- Added `getBlogTagFacets` API call to fetch blog tags based on city scope.
- Updated `BlogsPage` to read selected tag from URL and handle tag changes with URL updates.
- Modified `Title` component to display tags from the new API and reflect active tag state.
- Enhanced breadcrumb navigation to link categories to their respective pages.
- Adjusted related content section to display articles from the same category and fixed layout issues.
- Corrected heading hierarchy across various components for better SEO compliance.
- Ensured consistent styling and spacing in related content items.
This commit is contained in:
hamed
2026-07-29 14:23:37 +03:30
parent 533449c504
commit eb83762016
13 changed files with 714 additions and 65 deletions
@@ -0,0 +1,592 @@
# صفحهٔ بلاگ: بردکرامب دسته‌بندی، فیلتر دسته‌بندی، و اصلاح ستون «مطالب مرتبط»
## پروژه
`nobat724_front` (سایت عمومی)
پرامپت همتا در backend: `clinicpro/.claude/prompt/blog-tag-filter-and-facets.md`
**آن پرامپت باید اول اجرا و تأیید شود.** این پرامپت به دو چیز از آن وابسته است:
۱) `GET /api/v1/blogs?tag=<name>` که الان همیشه لیست خالی می‌دهد، ۲) endpoint جدید
`GET /api/v1/blogs/tags` برای واژگان چیپ‌ها.
## زمینه
صفحهٔ `https://yasuj-nobat.ir/blogs` و صفحهٔ جزئیات مقاله چند ایراد هم‌زمان دارند: فیلتر
دسته‌بندی هیچ‌وقت نتیجه نمی‌دهد، دسته‌بندی در بردکرامب لینک ندارد (در حالی که خودش یک صفحهٔ
مقصد دارد/باید داشته باشد)، ستون «مطالب مرتبط» عرض و ارتفاع صفحه را می‌بلعد و اصلاً «مرتبط»
نیست، و سلسله‌مراتب heading در هر دو صفحه شکسته است.
## مشکل / هدف
### ۱) بردکرامب دسته‌بندی لینک ندارد
در `components/blog/head/index.js` بردکرامب این شکل رندر می‌شود:
```
بلاگ > چشم و گوش > علل و اهمیت بررسی تخصصی وقتی هاله‌های رنگی اطراف نورها را می‌بینید
↑ لینک ↑ <p> ساده، بدون لینک
```
«چشم و گوش» یک دسته‌بندی واقعی است (۱۶ مقاله دارد) و باید به صفحهٔ خودش برود.
JSON-LD `BreadcrumbList` در `app/blog/[slug]/page.js` هم فقط سه سطح دارد و دسته را ندارد.
### ۲) فیلتر دسته‌بندی در `/blogs` کار نمی‌کند
دو علت مستقل:
- **backend** (در پرامپت همتا رفع می‌شود): `?tag=<فارسی>` همیشه `totalRecords: 0` می‌دهد.
- **frontend**: منبع چیپ‌ها ناقص است و انتخاب در URL ثبت نمی‌شود، پس لینک‌پذیر/قابل‌اشتراک
نیست، refresh آن را از دست می‌دهد، و بردکرامب هم جایی برای اشاره کردن ندارد.
`components/blogs/title/index.js` چیپ‌ها را از `getBlogs({page:1, limit:50})` می‌سازد؛ سقف
`limit` در backend ۵۰ است و ۱۱۷ مقالهٔ منتشرشده وجود دارد → تگ‌های صفحات بعد هرگز چیپ نمی‌شوند.
`city_id` هم پاس داده نمی‌شود، پس روی دامنهٔ شهری ممکن است چیپی نمایش داده شود که هیچ پستی روی
آن دامنه ندارد و کلیک روی آن «مقاله‌ای یافت نشد» می‌دهد.
### ۳) ستون «مطالب مرتبط» بزرگ است — و مرتبط نیست
- `components/blog/relatedContent/index.js` با `lg:w-fit` عرض ندارد؛ عرض ستون را **عنوانِ
بلندترین مقاله** تعیین می‌کند، چون `Item.js` عنوان را با `lg:text-nowrap` رندر می‌کند.
- `Item.js` بین عنوان و تاریخ `gap-[24px]` دارد و هر ردیف `py-[20px]` → کارت خیلی بلند می‌شود.
- محتوای این ستون اصلاً مرتبط نیست: `app/blog/[slug]/page.js:132` صرفاً ۶ مقالهٔ آخر را
می‌گیرد و ۴ تای اول (به‌جز خود مقاله) را نشان می‌دهد.
### ۴) سلسله‌مراتب heading شکسته است («فونت و ...»)
- `components/blogs/latestArticles/Article.js:27` — عنوان **هر کارت** `<h1>` است؛ صفحهٔ
`/blogs` با ۱۲ کارت، ۱۲ تا `<h1>` دارد و هیچ `<h1>` واقعی برای خود صفحه ندارد.
- `components/blog/relatedContent/Item.js:25` — عنوان هر مقالهٔ سایدبار `<h2>` است و با
`<h2>`های خودِ متن مقاله (`.blog-content h2` در `app/globals.css:920`، ۲۰px) هم‌رده می‌شود.
- `components/blog/head/index.js:36``<h1>` مقاله در دسکتاپ `24px` است و `.blog-content h2`
`20px`؛ اختلاف ۴px یعنی عنوان اصلی عملاً از تیترهای داخلی متمایز نیست.
## معیار پذیرش
- ✅ موفق: در صفحهٔ مقاله، کلیک روی «چشم و گوش» در بردکرامب → `/blogs?tag=چشم و گوش`؛ صفحه با
همان چیپ فعال باز می‌شود و فقط مقالات آن دسته را نشان می‌دهد.
- ✅ موفق: کلیک روی هر چیپ در `/blogs` → آدرس مرورگر به `?tag=<name>` تغییر می‌کند (بدون reload
کامل)، لیست فیلتر می‌شود و `page` به ۱ برمی‌گردد.
- ✅ موفق: refresh روی `/blogs?tag=زنان و بارداری` همان لیست فیلترشده و همان چیپ فعال را
بازتولید می‌کند.
- ✅ موفق: چیپ‌ها از `GET /api/v1/blogs/tags?city_id=<id>` می‌آیند و روی دامنهٔ شهری فقط تگ‌هایی
را نشان می‌دهند که روی همان دامنه پست دارند.
- ✅ موفق: JSON-LD `BreadcrumbList` صفحهٔ مقاله ۴ سطح دارد (خانه → مقالات → دستهٔ مقاله → عنوان)
و در [Rich Results Test](https://search.google.com/test/rich-results) بدون خطا اعتبارسنجی
می‌شود؛ هر `ListItem` فیلد `item` دارد.
- ✅ موفق: در `/blogs` دقیقاً **یک** `<h1>` وجود دارد؛ در صفحهٔ مقاله هم دقیقاً یک `<h1>` و
عناوین سایدبار در سطح پایین‌تر از `<h2>`های متن‌اند.
- ✅ موفق: ستون «مطالب مرتبط» در دسکتاپ عرض ثابت دارد و با تغییر طول عنوان‌ها جابه‌جا نمی‌شود؛
عنوان‌ها حداکثر دو خط (`line-clamp-2`).
- ✅ موفق: «مطالب مرتبط» مقالاتی از **همان دستهٔ** مقالهٔ جاری است.
- ❌ خطا: `/blogs?tag=یک‌چیز‌ناموجود` → بدون کرش، پیام موجودِ «مقاله‌ای یافت نشد»، و چیپ «همه»
فعال می‌ماند (چون چیپ متناظری وجود ندارد).
- ❌ خطا: اگر `GET /api/v1/blogs/tags` خطا داد، ردیف چیپ‌ها رندر نشود ولی لیست مقالات و بقیهٔ
صفحه سالم بماند (مثل رفتار فعلی `catch` که `setTags([])` می‌کند).
- ⚠️ مرزی: مقاله‌ای که تگ ندارد → بردکرامب سه‌سطحی می‌ماند (بدون `>` اضافه یا آیتم خالی) و
JSON-LD هم سه‌سطحی و معتبر بماند.
- ⚠️ مرزی: تگ فارسی با «،» و فاصله (`تنفس، آلرژی و عفونت`) در URL درست encode/decode شود و
رفت‌وبرگشت بردکرامب ← چیپ فعال را بشکند نه.
- ⚠️ مرزی: وقتی «مطالب مرتبطِ هم‌دسته» کمتر از ۴ مورد باشد (مثلاً `قلب و عروق` که ۱ پست دارد)،
باقی از آخرین مقالات پر شود و هیچ‌وقت خودِ مقالهٔ جاری تکرار نشود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `app/blogs/page.js` | صفحهٔ لیست (server) — `getStateInfo`، پاس دادن `cityId` |
| `components/blogs/index.js` | کلاینت لیست — state صفحه/تگ، fetch |
| `components/blogs/title/index.js` | منبع و رندر چیپ‌های دسته‌بندی |
| `components/blogs/title/ItemTitle.js` | چیپ تکی (MUI Button) |
| `components/blogs/latestArticles/Article.js` | کارت مقاله (`<h1>` اشتباه) |
| `components/blog/head/index.js` | بردکرامب + `<h1>` + متادیتای مقاله |
| `components/blog/relatedContent/index.js` | ظرف ستون «مطالب مرتبط» |
| `components/blog/relatedContent/Item.js` | ردیف مقالهٔ مرتبط |
| `components/blog/index.js` | چیدمان دو ستونیِ صفحهٔ مقاله |
| `app/blog/[slug]/page.js` | fetch مقالات مرتبط + JSON-LD بردکرامب |
| `services/response.js` | `request.getBlogs` — محل افزودن `getBlogTagFacets` |
| `app/globals.css` | `.blog-content` (خط ۹۱۱ به بعد) |
## وضعیت فعلی
`components/blog/head/index.js:12-34` — بردکرامب، دسته بدون لینک:
```jsx
<div className="flex items-center justify-start gap-1">
<Link
href="/blogs"
className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal hover:text-[#525252]"
>
بلاگ
</Link>
<span className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{">"}
</span>
{firstTag && (
<>
<p className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{firstTag.name} {">"}
</p>
</>
)}
<p className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{data?.title}
</p>
</div>
```
`components/blogs/index.js:10-58` — state تگ فقط در حافظه، بدون URL:
```jsx
function BlogsPage({ cityId = null }) {
const [blogs, setBlogs] = useState([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [selectedTag, setSelectedTag] = useState(null);
useEffect(() => {
const fetchBlogs = async () => {
try {
setIsLoading(true);
const params = { page: page, limit: 12 };
if (selectedTag) {
params.tag = selectedTag;
}
// backend با city_id پست‌های آن شهر «و» پست‌های سراسری را برمی‌گرداند
if (cityId) {
params.city_id = cityId;
}
const response = await request.getBlogs(params);
setBlogs((response?.data || []).map(normalizeBlog));
setTotalPages(response?.meta?.totalPages || 1);
} catch (error) {
console.error("Error fetching blogs:", error);
setBlogs([]);
} finally {
setIsLoading(false);
}
};
fetchBlogs();
}, [page, selectedTag, cityId]);
```
`components/blogs/title/index.js:10-35` — منبع ناقص چیپ‌ها:
```jsx
useEffect(() => {
const fetchTags = async () => {
try {
// Blog tags are stored as names on each blog; build the chip list from
// the tag names actually present in published blogs.
const response = await request.getBlogs({ page: 1, limit: 50 });
const names = (response?.data || []).flatMap((b) =>
Array.isArray(b.tags) ? b.tags : []
);
const uniqueNames = [...new Set(names)];
setTags(uniqueNames.map((name) => ({ name })));
} catch (error) {
console.error("Error fetching tags:", error);
setTags([]);
}
};
fetchTags();
}, []);
```
`app/blog/[slug]/page.js:127-138` — «مرتبط» = صرفاً آخرین‌ها:
```jsx
const { matchedCity, isRoot } = await getStateInfo();
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
let relatedBlogs = [];
const relatedResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeCityId != null ? `&city_id=${scopeCityId}` : ""}`
);
relatedBlogs = (relatedResponse?.data || [])
.filter((b) => b.uuid !== blog?.uuid)
.slice(0, 4)
.map(normalizeBlog);
```
`app/blog/[slug]/page.js:184-196` — بردکرامب JSON-LD سه‌سطحی:
```jsx
const breadcrumbJsonLd = blog
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر می‌کند
{ "@type": "ListItem", position: 3, name: blog.title,
item: `${origin}/blog/${slug}` },
],
}
: null;
```
`components/blog/relatedContent/index.js:8-19` و `Item.js:12-35` — عرض/ارتفاع بی‌مهار:
```jsx
<div className="p-[12px] w-full lg:w-fit md:p-[14px] lg:p-[16px] bg-[#FFF] rounded-[8px] shadow-[...]">
<AnimationTextHead text="مطالب مرتبط"><UnderlineLG /></AnimationTextHead>
<ul className="flex w-full lg:w-fit flex-col justify-start items-start">
```
```jsx
<li className="flex relative items-center justify-start gap-[8px] py-[12px] sm:py-[15px] md:py-[18px] lg:py-[20px]">
...
<div className="flex flex-col gap-[16px] sm:gap-[18px] md:gap-[21px] lg:gap-[24px] items-start justify-between">
<h2 className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold text-wrap lg:text-nowrap">
```
`components/blogs/latestArticles/Article.js:27``<h1>` در کارت:
```jsx
<h1 className="text-[#3B3B3B] mb-[12px] md:mb-[14px] lg:mb-[16px] text-[16px] font-bold line-clamp-2">
{data.title}
</h1>
```
## وظایف
### ۱. افزودن `getBlogTagFacets` به `services/response.js`
کنار `getBlogs` (خط ۱۲۷) و با همان الگوی `Authorization: ""`:
```js
getBlogTagFacets: (params) =>
api.get("api/v1/blogs/tags", {
params,
headers: {
Authorization: "",
},
}),
```
پاسخ backend: `{ success: true, data: [{ name, count }, …] }` (بدون double-nesting).
interceptor در `services/api.js:78` خودِ `response.data` را برمی‌گرداند، پس در کامپوننت
`response?.data` همان آرایه است.
`getBlogTags` موجود (که `api/v1/tags` را صدا می‌زند) **حذف نشود** — واژگان دیگری است و ممکن
است جای دیگری مصرف شود؛ فقط برای چیپ‌های بلاگ استفاده نشود.
**نحوه تست:** در کنسول مرورگر روی `/blogs`:
`await (await import('/services/response.js')).request.getBlogTagFacets({city_id: 132})`
یا ساده‌تر، بعد از وظیفهٔ ۳ در Network tab ببین `api/v1/blogs/tags?city_id=…` زده می‌شود و
`200` با آرایهٔ ناخالی برمی‌گردد.
### ۲. تگ انتخابی را به URL منتقل کن (`?tag=`)
**دلیل انتخاب این راه‌حل:** بردکرامبِ صفحهٔ مقاله باید به «صفحهٔ دسته» لینک بدهد. ساختن یک روت
جدید `app/blogs/[tag]/page.js` هم ممکن بود، ولی همان لیست را با همان کامپوننت‌ها تکرار می‌کرد،
دو canonical برای یک محتوا می‌ساخت و به `generateStaticParams` روی واژگان آزادِ فارسی نیاز
داشت. استفاده از query param روی همان روت، صفحهٔ مقصد را می‌دهد بدون تکرار روت و بدون ریسک
duplicate content — همان الگوی فیلترهای موجود سایت.
**منبع حقیقت = URL**، نه state داخلی. در `components/blogs/index.js`:
```jsx
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
// …
function BlogsPage({ cityId = null }) {
const router = useRouter();
const searchParams = useSearchParams();
// تگ انتخابی از URL خوانده می‌شود تا لینک بردکرامب، refresh و اشتراک‌گذاری همگی
// یک حالت را بازتولید کنند؛ state داخلی دومین منبعِ حقیقت می‌شد.
const selectedTag = searchParams.get("tag") || null;
const [blogs, setBlogs] = useState([]);
const [page, setPage] = useState(1);
// …
const handleTagChange = (tagName) => {
const params = new URLSearchParams(searchParams.toString());
if (tagName) {
params.set("tag", tagName);
} else {
params.delete("tag");
}
setPage(1);
router.replace(params.toString() ? `/blogs?${params}` : "/blogs", {
scroll: false,
});
};
```
`useEffect` فعلی بدون تغییر می‌ماند (وابستگی `selectedTag` هنوز درست است، فقط منبعش عوض شده).
`selectedTag` باید به `<Title>` هم پاس داده شود تا چیپ فعال از URL بیاید:
```jsx
<Title selectedTag={selectedTag} onTagChange={handleTagChange} cityId={cityId} />
```
چون `useSearchParams` در client component استفاده می‌شود، در `app/blogs/page.js` باید داخل
`<Suspense>` قرار بگیرد (الزام Next 15):
```jsx
import { Suspense } from "react";
// …
<Layout name="/blogs">
<Suspense fallback={null}>
<BlogsPage cityId={isRoot ? null : (matchedCity?.id ?? null)} />
</Suspense>
</Layout>
```
**نحوه تست:**
```bash
npm run dev # http://yazd-nobat.localhost:3000
```
1. `/blogs` → کلیک روی «چشم و گوش» → آدرس به `?tag=چشم و گوش` تغییر کند بدون reload کامل،
لیست فیلتر شود.
2. F5 روی همان آدرس → همان لیست و همان چیپ فعال.
3. کلیک روی «همه» → `tag` از URL حذف شود.
4. `/blogs?tag=nope` → «مقاله‌ای یافت نشد»، چیپ «همه» فعال، بدون کرش کنسول.
5. `/blogs?tag=تنفس، آلرژی و عفونت` (کپی‌پیست از بردکرامب) → لیست درست، چیپ درست فعال.
### ۳. چیپ‌ها از endpoint facet، و فعال‌بودن بر اساس نام نه ایندکس
`components/blogs/title/index.js`:
```jsx
function Title({ selectedTag, onTagChange, cityId }) {
const [tags, setTags] = useState([]);
useEffect(() => {
const fetchTags = async () => {
try {
// واژگان کامل تگ‌های مقالات منتشرشده در scope همین دامنه. استخراج از خودِ
// لیست مقاله‌ها ممکن نیست: سقف limit برابر ۵۰ است و مقالات بیشتری وجود دارد.
const response = await request.getBlogTagFacets(
cityId ? { city_id: cityId } : {}
);
setTags(response?.data || []);
} catch (error) {
console.error("Error fetching blog tags:", error);
setTags([]);
}
};
fetchTags();
}, [cityId]);
if (tags.length === 0) return null;
return (
<ul className="flex w-full overflow-auto hidden-scroll items-center justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
<ItemTitle
name="همه"
isActive={!selectedTag}
onSelect={() => onTagChange(null)}
key="all"
/>
{tags.map((tag) => (
<ItemTitle
name={tag.name}
isActive={selectedTag === tag.name}
onSelect={() => onTagChange(tag.name)}
key={tag.name}
/>
))}
</ul>
);
}
```
`ItemTitle.js` هم به همین قرارداد ساده‌تر تغییر کند (`isActive`/`onSelect` به‌جای
`activeBtn`/`idx`/`setActiveBtn`)؛ ایندکس دیگر معنایی ندارد چون حالت از نام تگ می‌آید:
```jsx
function ItemTitle({ name, isActive, onSelect }) {
return (
<li>
<Button
className={`!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !shadow-none !rounded-[8px] !text-[14px] md:!text-[16px] !whitespace-nowrap ${
isActive ? "!bg-[#F17732] !text-[#FAFAFA]" : "!bg-[#F5F5F5] !text-[#3B3B3B]"
}`}
onClick={onSelect}
variant="contained"
color="secondary"
>
{name}
</Button>
</li>
);
}
```
> کلاس فعلی `md:!text-[16px` براکت بسته ندارد (خط ۷ فایل) — یعنی این کلاس هیچ‌وقت اعمال نشده.
> در بازنویسی اصلاح شود.
**نحوه تست:** Network tab → یک درخواست `api/v1/blogs/tags`؛ تعداد چیپ‌ها با تعداد تگ‌های
پاسخ برابر باشد. روی `yazd-nobat.localhost` تعداد چیپ‌ها باید ≤ حالت دامنهٔ ریشه باشد. کلیک روی
هر چیپ نباید «مقاله‌ای یافت نشد» بدهد (چون facet فقط تگ‌های دارای پست همان scope را می‌دهد).
### ۴. بردکرامب صفحهٔ مقاله: دسته را لینک کن (HTML + JSON-LD)
`components/blog/head/index.js` — دسته را `<Link>` کن و ساختار را با `<nav>` مرتب کن:
```jsx
<nav aria-label="مسیر" className="flex items-center flex-wrap justify-start gap-1">
<Link href="/blogs" className={crumbClass}>بلاگ</Link>
<span className={crumbClass}>{">"}</span>
{firstTag?.name && (
<>
<Link
href={`/blogs?tag=${encodeURIComponent(firstTag.name)}`}
className={`${crumbClass} hover:text-[#525252]`}
>
{firstTag.name}
</Link>
<span className={crumbClass}>{">"}</span>
</>
)}
<span className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{data?.title}
</span>
</nav>
```
(`crumbClass` همان رشتهٔ کلاس تکراری `text-[#9B9B9B] text-[11px] …` است؛ یک‌بار بالای کامپوننت
تعریف شود تا چهار جا تکرار نشود.)
`app/blog/[slug]/page.js` — دسته را به JSON-LD اضافه کن. `item` هر سطح اجباری است، و URL باید
دقیقاً همان لینک HTML باشد:
```jsx
// دستهٔ مقاله = تگ اول؛ همان چیزی که بردکرامب HTML نشان می‌دهد. مقالهٔ بدون تگ
// بردکرامب سه‌سطحی می‌گیرد — سطح خالی کل BreadcrumbList را نامعتبر می‌کند.
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
const breadcrumbJsonLd = blog
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
...(categoryName
? [{
"@type": "ListItem",
position: 3,
name: categoryName,
item: `${origin}/blogs?tag=${encodeURIComponent(categoryName)}`,
}]
: []),
{
"@type": "ListItem",
position: categoryName ? 4 : 3,
name: blog.title,
item: `${origin}/blog/${slug}`,
},
],
}
: null;
```
**نحوه تست:**
1. صفحهٔ مقاله‌ای با تگ → کلیک روی دسته → `/blogs?tag=…` با لیست فیلترشده باز شود.
2. `view-source` صفحه → JSON-LD بردکرامب چهار `ListItem` با `position` پشت‌سرهم ۱..۴ و
`item` غیرخالی دارد.
3. مقاله‌ای بدون تگ (اگر در دیتای محلی نیست، `tags` یک مقاله را از پنل ادمین خالی کن) →
سه سطح، `position` ۱..۳، بدون `>` اضافی در HTML.
4. JSON-LD را در `https://search.google.com/test/rich-results` (تب Code) بچسبان → بدون خطا.
### ۵. «مطالب مرتبط»: هم‌دسته کردن + مهار عرض و ارتفاع
**الف) واقعاً مرتبط شود** — در `app/blog/[slug]/page.js` اول با تگ مقاله بگیر، بعد با آخرین
مقالات تا ۴ تا پر کن (fallback، چون تگ‌هایی مثل «قلب و عروق» فقط یک پست دارند):
```jsx
const scopeQuery = scopeCityId != null ? `&city_id=${scopeCityId}` : "";
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
// اول هم‌دسته‌ها؛ اگر دسته کم‌پست بود با آخرین مقالات پر می‌شود تا ستون خالی نماند.
const sameTagResponse = categoryName
? await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6&tag=${encodeURIComponent(categoryName)}${scopeQuery}`
)
: null;
const latestResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeQuery}`
);
const seen = new Set([blog?.uuid]);
const relatedBlogs = [
...(sameTagResponse?.data || []),
...(latestResponse?.data || []),
]
.filter((b) => !seen.has(b.uuid) && seen.add(b.uuid))
.slice(0, 4)
.map(normalizeBlog);
```
**ب) عرض ستون را از عنوان بگیر و به layout بده**`relatedContent/index.js`:
`lg:w-fit` (روی `div` و `ul`) با عرض ثابت جایگزین شود، مثلاً `lg:w-[320px] lg:shrink-0`.
در `components/blog/index.js` ستون متن `lg:w-[68%]` است؛ بعد از تثبیت عرض سایدبار، آن را به
`lg:flex-1 lg:min-w-0` تغییر بده تا مجموع دو ستون از `gap` سرریز نکند.
**ج) ردیف‌ها را جمع کن**`relatedContent/Item.js`:
- `lg:text-nowrap` حذف و `line-clamp-2` اضافه شود.
- `gap-[16px] … lg:gap-[24px]` بین عنوان و تاریخ به `gap-[6px]` کاهش یابد و
`items-center` روی `<li>` به `items-start` تغییر کند.
- `py-[12px] … lg:py-[20px]` به حداکثر `py-[12px]` کاهش یابد.
- عنوان از `<h2>` به `<h3>` تغییر کند (وظیفهٔ ۶).
**نحوه تست:** صفحهٔ مقالهٔ اسکرین‌شات (`چشم و گوش`) در عرض ۱۴۴۰px:
عنوان‌های سایدبار حداکثر دو خط؛ عرض سایدبار با باز کردن دو مقالهٔ مختلف تغییر نکند؛ ارتفاع کل
ستون از ارتفاع تصویر شاخصِ مقاله کمتر یا نزدیک به آن باشد. سپس عرض ۳۷۵px (موبایل): سایدبار
تمام‌عرض زیر متن، بدون اسکرول افقی صفحه. در Network بررسی کن که درخواست
`?tag=<دسته>` واقعاً زده می‌شود و آیتم‌های سایدبار هم‌دسته‌اند.
### ۶. اصلاح سلسله‌مراتب heading
- `components/blogs/latestArticles/Article.js:27``<h1>``<h2>`.
- `components/blogs/latestArticles/index.js:7` — «جدیدترین مقاله ها» از `<p>` به `<h1>` تبدیل
شود تا `/blogs` دقیقاً یک `<h1>` داشته باشد (کلاس‌ها دست‌نخورده بمانند).
- `components/blog/relatedContent/Item.js:25``<h2>``<h3>`.
- `components/blog/head/index.js:36` — اندازهٔ `<h1>` مقاله به
`text-[18px] sm:text-[22px] md:text-[26px] lg:text-[30px]` افزایش یابد تا از
`.blog-content h2` (۲۰px) به‌وضوح متمایز شود.
**نحوه تست:** در کنسول مرورگر روی هر صفحه:
```js
$$('h1,h2,h3,h4,h5,h6').map(h => h.tagName + ' · ' + h.textContent.trim().slice(0,40))
```
انتظار `/blogs`: یک `H1` («جدیدترین مقاله ها») و بقیه `H2`.
انتظار صفحهٔ مقاله: یک `H1` (عنوان مقاله)، `H2`های متن مقاله، `H3`های سایدبار.
## نکات مهم
- **وابستگی به backend:** تا وقتی پرامپت `clinicpro/.claude/prompt/blog-tag-filter-and-facets.md`
اجرا نشده، وظایف ۲/۳/۵-الف قابل تست نیستند (فیلتر همیشه خالی برمی‌گردد و
`/api/v1/blogs/tags` وجود ندارد). ترتیب اجرا رعایت شود.
- **تغییر قرارداد در build خطا نمی‌دهد:** فراخوانی `getBlogTagFacets` روی endpointِ نساخته
فقط در رانتایم ۴۰۴ می‌گیرد و `catch` آن را می‌بلعد؛ حتماً Network tab بررسی شود، نه فقط
«صفحه بالا آمد».
- **`useSearchParams` بدون `<Suspense>` در Next 15 صفحه را می‌شکند** (خطای build/prerender).
اگر `app/blogs/loading.js` را به‌عنوان fallback ترجیح می‌دهی، همان را در `Suspense` بگذار.
- **canonical دست نخورد:** `/blogs?tag=…` نباید canonical جدید تولید کند — لایهٔ layout
self-canonical روی `/blogs` می‌گذارد و همین درست است؛ صفحهٔ فیلترشده نسخهٔ دیگری از همان
لیست است، نه محتوای جدید. اگر `generateMetadata` صفحهٔ `/blogs` تغییر داده شد، این را نقض نکن.
- **encode/decode:** برای ساخت لینک از `encodeURIComponent` استفاده شود؛ برای خواندن،
`searchParams.get("tag")` خودش decode می‌کند — دوباره `decodeURIComponent` نزن (تگ‌های حاوی
`%` را خراب می‌کند).
- **`normalizeBlog` تفاوت `tags` و `tag` را می‌سازد:** پاسخ خام API فیلد `tags: ["نام"]` دارد؛
بعد از `normalizeBlog` می‌شود `tag: [{name}]`. در `title/index.js` (خام) از `name` پاسخ facet
استفاده کن و در صفحهٔ مقاله (نرمال‌شده) از `blog.tag[0].name`. قاطی کردن این دو، منبع
باگ فعلی است.
- **بدون کامپوننت جدید:** همهٔ تغییرها داخل کامپوننت‌های موجود انجام شود؛ چیزی abstract نشود
«برای آینده». تنها قرارداد جدید، prop‌های `isActive`/`onSelect` در `ItemTitle` است که یک
API واقعاً ساده‌تر را جایگزین سه prop ایندکس‌محور می‌کند.
- **الزام پروژه:** صفحهٔ جدیدی ساخته نمی‌شود و طراحی جدیدی معرفی نمی‌شود؛ همان تم، همان
رنگ‌ها (`#F17732`, `#3B3B3B`, `#9B9B9B`)، همان فونت Vazir و همان اسپیسینگ‌های موجود.
- **بعد از پایان:** `npm run lint` و `npm run build` هر دو سبز باشند.
+32 -6
View File
@@ -128,12 +128,28 @@ async function Blog({ params }) {
// پستِ شهر دیگر نشان داده می‌شود که حالا ۴۰۴ می‌گیرد.
const { matchedCity, isRoot } = await getStateInfo();
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
let relatedBlogs = [];
const relatedResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeCityId != null ? `&city_id=${scopeCityId}` : ""}`
const scopeQuery = scopeCityId != null ? `&city_id=${scopeCityId}` : "";
// دستهٔ مقاله = تگ اول؛ هم بردکرامب و هم مقالات مرتبط از همین می‌آیند.
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
// اول هم‌دسته‌ها؛ دسته‌های کم‌پست (مثلاً «قلب و عروق» با یک مقاله) با آخرین
// مقالات پر می‌شوند تا ستون خالی یا کوتاه نماند.
const sameTagResponse = categoryName
? await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6&tag=${encodeURIComponent(categoryName)}${scopeQuery}`
)
: null;
const latestResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeQuery}`
);
relatedBlogs = (relatedResponse?.data || [])
.filter((b) => b.uuid !== blog?.uuid)
const seen = new Set([blog?.uuid]);
const relatedBlogs = [
...(sameTagResponse?.data || []),
...(latestResponse?.data || []),
]
.filter((b) => !seen.has(b.uuid) && seen.add(b.uuid))
.slice(0, 4)
.map(normalizeBlog);
@@ -188,8 +204,18 @@ async function Blog({ params }) {
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
// دستهٔ مقاله = همان چیزی که بردکرامب HTML نشان می‌دهد؛ مقالهٔ بدون تگ
// سه‌سطحی می‌ماند، چون سطح خالی کل BreadcrumbList را نامعتبر می‌کند.
...(categoryName
? [{
"@type": "ListItem",
position: 3,
name: categoryName,
item: `${origin}/blogs?tag=${encodeURIComponent(categoryName)}`,
}]
: []),
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر می‌کند
{ "@type": "ListItem", position: 3, name: blog.title,
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
item: `${origin}/blog/${slug}` },
],
}
+5
View File
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import BlogsPage from "@/components/blogs";
import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
@@ -33,7 +34,11 @@ export default async function Blogs() {
// تشخیص شهر سمت سرور انجام می‌شود (همان الگوی app/specialties/page.js).
return (
<Layout name="/blogs">
{/* BlogsPage تگ انتخابی را از useSearchParams می‌خواند؛ Next 15 برای آن
مرز Suspense لازم دارد وگرنه prerender صفحه می‌شکند. */}
<Suspense fallback={null}>
<BlogsPage cityId={isRoot ? null : (matchedCity?.id ?? null)} />
</Suspense>
</Layout>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import CommentUser from "./commentUser";
function Detail({ data }) {
return (
<div className="w-full lg:w-[68%]">
<div className="w-full lg:flex-1 lg:min-w-0">
<Caption data={data} />
<Faq data={data} />
<Sources data={data} />
+21 -15
View File
@@ -1,6 +1,9 @@
import Link from "next/link";
import { convertTimestampToJalali } from "@/helper";
const crumbClass =
"text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal";
function Head({ data, cityName }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
@@ -11,29 +14,32 @@ function Head({ data, cityName }) {
return (
<div>
<div className="flex items-center justify-start gap-1">
<Link
href="/blogs"
className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal hover:text-[#525252]"
<nav
aria-label="مسیر"
className="flex flex-wrap items-center justify-start gap-1"
>
<Link href="/blogs" className={`${crumbClass} hover:text-[#525252]`}>
بلاگ
</Link>
<span className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{">"}
</span>
{firstTag && (
<span className={crumbClass}>{">"}</span>
{firstTag?.name && (
<>
<p className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{firstTag.name} {">"}
</p>
{/* دسته‌بندی خودش یک صفحهٔ مقصد دارد: همان لیست مقاله‌ها فیلترشده. */}
<Link
href={`/blogs?tag=${encodeURIComponent(firstTag.name)}`}
className={`${crumbClass} hover:text-[#525252]`}
>
{firstTag.name}
</Link>
<span className={crumbClass}>{">"}</span>
</>
)}
<p className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
<span className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
{data?.title}
</p>
</div>
</span>
</nav>
<div>
<h1 className="text-[#3B3B3B] mt-[24px] sm:mt-[29px] md:mt-[35px] lg:mt-[40px] text-[14px] sm:text-[17px] md:text-[20px] lg:text-[24px] font-bold">
<h1 className="text-[#3B3B3B] mt-[24px] sm:mt-[29px] md:mt-[35px] lg:mt-[40px] text-[18px] sm:text-[22px] md:text-[26px] lg:text-[30px] font-bold leading-[1.6]">
{data?.title}
</h1>
</div>
+5 -4
View File
@@ -9,7 +9,7 @@ function Item({ data, idx, loading }) {
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
return (
<li className="flex relative items-center justify-start gap-[8px] py-[12px] sm:py-[15px] md:py-[18px] lg:py-[20px]">
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<img
@@ -19,12 +19,13 @@ function Item({ data, idx, loading }) {
/>
</Link>
</CustomLoading>
<div className="flex flex-col gap-[16px] sm:gap-[18px] md:gap-[21px] lg:gap-[24px] items-start justify-between">
<div className="flex min-w-0 flex-col gap-[6px] items-start justify-start">
<TextLoading width={150} height={18} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<h2 className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold text-wrap lg:text-nowrap">
{/* h3 چون h2 با تیترهای خودِ متن مقاله (.blog-content h2) هم‌رده می‌شد. */}
<h3 className="text-[#3B3B3B] text-[14px] md:text-[15px] font-bold leading-[1.8] line-clamp-2">
{data.title}
</h2>
</h3>
</Link>
</TextLoading>
<TextLoading width={110} height={18} loading={loading}>
+4 -2
View File
@@ -3,13 +3,15 @@ import UnderlineLG from "@/components/icons/UnderlineLG";
import React from "react";
import Item from "./Item";
// عرض ستون ثابت است: با lg:w-fit عرض را بلندترین عنوان تعیین می‌کرد و با هر
// مقاله جابه‌جا می‌شد.
function RelatedContent({ data, loading }) {
return (
<div className="p-[12px] w-full lg:w-fit md:p-[14px] lg:p-[16px] bg-[#FFF] rounded-[8px] shadow-[0px_1px_24.8px_0px_rgba(204,_204,_204,_0.18)]">
<div className="p-[12px] w-full lg:w-[320px] lg:shrink-0 md:p-[14px] lg:p-[16px] bg-[#FFF] rounded-[8px] shadow-[0px_1px_24.8px_0px_rgba(204,_204,_204,_0.18)]">
<AnimationTextHead text="مطالب مرتبط">
<UnderlineLG />
</AnimationTextHead>
<ul className="flex w-full lg:w-fit flex-col justify-start items-start">
<ul className="flex w-full flex-col justify-start items-start">
{data &&
data.length > 0 &&
data?.map((item, idx) => (
+22 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Head from "./Head";
import Title from "./title";
import LatestArticles from "./latestArticles";
@@ -8,11 +9,16 @@ import { request } from "@/services/response";
import { normalizeBlog } from "@/helper";
function BlogsPage({ cityId = null }) {
const router = useRouter();
const searchParams = useSearchParams();
// تگ انتخابی از URL خوانده می‌شود تا لینک بردکرامب صفحهٔ مقاله، refresh و
// اشتراک‌گذاری همگی یک حالت را بازتولید کنند؛ state داخلی منبع دومِ حقیقت می‌شد.
const selectedTag = searchParams.get("tag") || null;
const [blogs, setBlogs] = useState([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [selectedTag, setSelectedTag] = useState(null);
useEffect(() => {
const fetchBlogs = async () => {
@@ -48,14 +54,26 @@ function BlogsPage({ cityId = null }) {
window.scrollTo({ top: 0, behavior: "smooth" });
};
const handleTagChange = (tagUuid) => {
setSelectedTag(tagUuid);
const handleTagChange = (tagName) => {
const params = new URLSearchParams(searchParams.toString());
if (tagName) {
params.set("tag", tagName);
} else {
params.delete("tag");
}
setPage(1);
router.replace(params.toString() ? `/blogs?${params}` : "/blogs", {
scroll: false,
});
};
return (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Title onTagChange={handleTagChange} />
<Title
selectedTag={selectedTag}
onTagChange={handleTagChange}
cityId={cityId}
/>
<Head />
<LatestArticles
blogs={blogs}
+2 -2
View File
@@ -24,9 +24,9 @@ function Article({ data }) {
<div className="p-[12px] md:p-[14px] lg:p-[16px]">
<TextLoading width={150} height={18}>
<Link href={`/blog/${data.uuid}`}>
<h1 className="text-[#3B3B3B] mb-[12px] md:mb-[14px] lg:mb-[16px] text-[16px] font-bold line-clamp-2">
<h2 className="text-[#3B3B3B] mb-[12px] md:mb-[14px] lg:mb-[16px] text-[16px] font-bold line-clamp-2">
{data.title}
</h1>
</h2>
</Link>
</TextLoading>
<div className="flex items-center justify-start gap-[16px]">
+2 -2
View File
@@ -4,9 +4,9 @@ import Pagination from "@/app/component/Pagination";
function LatestArticles({ blogs, page, totalPages, onPageChange, isLoading }) {
return (
<div>
<p className="text-[#3B3B3B] text-[20px] font-bold my-[16px] md:my-[20px] lg:my-[24px]">
<h1 className="text-[#3B3B3B] text-[20px] font-bold my-[16px] md:my-[20px] lg:my-[24px]">
جدیدترین مقاله ها
</p>
</h1>
{isLoading ? (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-[#9B9B9B] text-[16px]">در حال بارگذاری...</p>
+3 -3
View File
@@ -1,11 +1,11 @@
import { Button } from "@mui/material";
function ItemTitle({ name, idx, activeBtn, setActiveBtn }) {
function ItemTitle({ name, isActive, onSelect }) {
return (
<li>
<Button
className={` !py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !shadow-none !rounded-[8px] !text-[14px] md:!text-[16px ${ activeBtn === idx ? "!bg-[#F17732] !text-[#FAFAFA]" : "!bg-[#F5F5F5] !text-[#3B3B3B]" } `}
onClick={() => setActiveBtn(idx)}
className={` !py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !shadow-none !rounded-[8px] !text-[14px] md:!text-[16px] !whitespace-nowrap ${ isActive ? "!bg-[#F17732] !text-[#FAFAFA]" : "!bg-[#F5F5F5] !text-[#3B3B3B]" } `}
onClick={onSelect}
variant="contained"
color="secondary"
>
+14 -24
View File
@@ -3,52 +3,42 @@ import { useState, useEffect } from "react";
import ItemTitle from "./ItemTitle";
import { request } from "@/services/response";
function Title({ onTagChange }) {
const [activeBtn, setActiveBtn] = useState(0);
function Title({ selectedTag, onTagChange, cityId }) {
const [tags, setTags] = useState([]);
useEffect(() => {
const fetchTags = async () => {
try {
// Blog tags are stored as names on each blog; build the chip list from
// the tag names actually present in published blogs.
const response = await request.getBlogs({ page: 1, limit: 50 });
const names = (response?.data || []).flatMap((b) =>
Array.isArray(b.tags) ? b.tags : []
// واژگان کامل تگ‌های مقالات منتشرشده در scope همین دامنه. استخراج از خودِ
// لیست مقاله‌ها ممکن نیست: سقف limit برابر ۵۰ است و مقاله‌ها بیشترند.
const response = await request.getBlogTagFacets(
cityId ? { city_id: cityId } : {}
);
const uniqueNames = [...new Set(names)];
setTags(uniqueNames.map((name) => ({ name })));
setTags(response?.data || []);
} catch (error) {
console.error("Error fetching tags:", error);
console.error("Error fetching blog tags:", error);
setTags([]);
}
};
fetchTags();
}, []);
}, [cityId]);
const handleTagClick = (idx, tagName) => {
setActiveBtn(idx);
if (onTagChange) {
onTagChange(tagName);
}
};
if (tags.length === 0) return null;
return (
<ul className="flex w-full overflow-auto hidden-scroll items-center justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
<ItemTitle
setActiveBtn={() => handleTagClick(0, null)}
activeBtn={activeBtn}
name="همه"
idx={0}
isActive={!selectedTag}
onSelect={() => onTagChange(null)}
key="all"
/>
{tags.map((tag, idx) => (
{tags.map((tag) => (
<ItemTitle
setActiveBtn={() => handleTagClick(idx + 1, tag.name)}
activeBtn={activeBtn}
name={tag.name}
idx={idx + 1}
isActive={selectedTag === tag.name}
onSelect={() => onTagChange(tag.name)}
key={tag.name}
/>
))}
+9
View File
@@ -131,6 +131,15 @@ export const request = {
Authorization: "",
},
}),
// واژگان تگ‌های مقالات منتشرشده + تعداد هرکدام. با getBlogTags یکی نیست: آن
// واژگانِ Tag entity است و به مقاله‌ها وصل نیست.
getBlogTagFacets: (params) =>
api.get("api/v1/blogs/tags", {
params,
headers: {
Authorization: "",
},
}),
getBlogTags: (params) =>
api.get("api/v1/tags", {
params,