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:
@@ -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
@@ -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}` },
|
||||
],
|
||||
}
|
||||
|
||||
+6
-1
@@ -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 cityId={isRoot ? null : (matchedCity?.id ?? null)} />
|
||||
{/* BlogsPage تگ انتخابی را از useSearchParams میخواند؛ Next 15 برای آن
|
||||
مرز Suspense لازم دارد وگرنه prerender صفحه میشکند. */}
|
||||
<Suspense fallback={null}>
|
||||
<BlogsPage cityId={isRoot ? null : (matchedCity?.id ?? null)} />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user