diff --git a/.claude/prompt/blog-category-breadcrumb-and-filter.md b/.claude/prompt/blog-category-breadcrumb-and-filter.md new file mode 100644 index 0000000..71c7537 --- /dev/null +++ b/.claude/prompt/blog-category-breadcrumb-and-filter.md @@ -0,0 +1,592 @@ +# صفحهٔ بلاگ: بردکرامب دسته‌بندی، فیلتر دسته‌بندی، و اصلاح ستون «مطالب مرتبط» + +## پروژه + +`nobat724_front` (سایت عمومی) + +پرامپت همتا در backend: `clinicpro/.claude/prompt/blog-tag-filter-and-facets.md` — +**آن پرامپت باید اول اجرا و تأیید شود.** این پرامپت به دو چیز از آن وابسته است: +۱) `GET /api/v1/blogs?tag=` که الان همیشه لیست خالی می‌دهد، ۲) endpoint جدید +`GET /api/v1/blogs/tags` برای واژگان چیپ‌ها. + +## زمینه + +صفحهٔ `https://yasuj-nobat.ir/blogs` و صفحهٔ جزئیات مقاله چند ایراد هم‌زمان دارند: فیلتر +دسته‌بندی هیچ‌وقت نتیجه نمی‌دهد، دسته‌بندی در بردکرامب لینک ندارد (در حالی که خودش یک صفحهٔ +مقصد دارد/باید داشته باشد)، ستون «مطالب مرتبط» عرض و ارتفاع صفحه را می‌بلعد و اصلاً «مرتبط» +نیست، و سلسله‌مراتب heading در هر دو صفحه شکسته است. + +## مشکل / هدف + +### ۱) بردکرامب دسته‌بندی لینک ندارد + +در `components/blog/head/index.js` بردکرامب این شکل رندر می‌شود: + +``` +بلاگ > چشم و گوش > علل و اهمیت بررسی تخصصی وقتی هاله‌های رنگی اطراف نورها را می‌بینید + ↑ لینک ↑

ساده، بدون لینک +``` + +«چشم و گوش» یک دسته‌بندی واقعی است (۱۶ مقاله دارد) و باید به صفحهٔ خودش برود. +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` — عنوان **هر کارت** `

` است؛ صفحهٔ + `/blogs` با ۱۲ کارت، ۱۲ تا `

` دارد و هیچ `

` واقعی برای خود صفحه ندارد. +- `components/blog/relatedContent/Item.js:25` — عنوان هر مقالهٔ سایدبار `

` است و با + `

`های خودِ متن مقاله (`.blog-content h2` در `app/globals.css:920`، ۲۰px) هم‌رده می‌شود. +- `components/blog/head/index.js:36` — `

` مقاله در دسکتاپ `24px` است و `.blog-content h2` + ‏`20px`؛ اختلاف ۴px یعنی عنوان اصلی عملاً از تیترهای داخلی متمایز نیست. + +## معیار پذیرش + +- ✅ موفق: در صفحهٔ مقاله، کلیک روی «چشم و گوش» در بردکرامب → `/blogs?tag=چشم و گوش`؛ صفحه با + همان چیپ فعال باز می‌شود و فقط مقالات آن دسته را نشان می‌دهد. +- ✅ موفق: کلیک روی هر چیپ در `/blogs` → آدرس مرورگر به `?tag=` تغییر می‌کند (بدون reload + کامل)، لیست فیلتر می‌شود و `page` به ۱ برمی‌گردد. +- ✅ موفق: refresh روی `/blogs?tag=زنان و بارداری` همان لیست فیلترشده و همان چیپ فعال را + بازتولید می‌کند. +- ✅ موفق: چیپ‌ها از `GET /api/v1/blogs/tags?city_id=` می‌آیند و روی دامنهٔ شهری فقط تگ‌هایی + را نشان می‌دهند که روی همان دامنه پست دارند. +- ✅ موفق: JSON-LD `BreadcrumbList` صفحهٔ مقاله ۴ سطح دارد (خانه → مقالات → دستهٔ مقاله → عنوان) + و در [Rich Results Test](https://search.google.com/test/rich-results) بدون خطا اعتبارسنجی + می‌شود؛ هر `ListItem` فیلد `item` دارد. +- ✅ موفق: در `/blogs` دقیقاً **یک** `

` وجود دارد؛ در صفحهٔ مقاله هم دقیقاً یک `

` و + عناوین سایدبار در سطح پایین‌تر از `

`های متن‌اند. +- ✅ موفق: ستون «مطالب مرتبط» در دسکتاپ عرض ثابت دارد و با تغییر طول عنوان‌ها جابه‌جا نمی‌شود؛ + عنوان‌ها حداکثر دو خط (`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` | کارت مقاله (`

` اشتباه) | +| `components/blog/head/index.js` | بردکرامب + `

` + متادیتای مقاله | +| `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 +
+ + بلاگ + + + {">"} + + {firstTag && ( + <> +

+ {firstTag.name} {">"} +

+ + )} +

+ {data?.title} +

+
+``` + +`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 +
+ +
    +``` + +```jsx +
  • + ... +
    +

    +``` + +`components/blogs/latestArticles/Article.js:27` — `

    ` در کارت: + +```jsx +

    + {data.title} +

    +``` + +## وظایف + +### ۱. افزودن `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` باید به `` هم پاس داده شود تا چیپ فعال از 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` هر دو سبز باشند. diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 0594b95..1c878bb 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -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}` }, ], } diff --git a/app/blogs/page.js b/app/blogs/page.js index 5074d33..8773e8d 100644 --- a/app/blogs/page.js +++ b/app/blogs/page.js @@ -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> ); } diff --git a/components/blog/detail/index.js b/components/blog/detail/index.js index 567f4ce..2d2cf97 100644 --- a/components/blog/detail/index.js +++ b/components/blog/detail/index.js @@ -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} /> diff --git a/components/blog/head/index.js b/components/blog/head/index.js index ec2c9e9..23553e6 100644 --- a/components/blog/head/index.js +++ b/components/blog/head/index.js @@ -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> diff --git a/components/blog/relatedContent/Item.js b/components/blog/relatedContent/Item.js index a411b17..a85f6e8 100644 --- a/components/blog/relatedContent/Item.js +++ b/components/blog/relatedContent/Item.js @@ -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}> diff --git a/components/blog/relatedContent/index.js b/components/blog/relatedContent/index.js index 9dcb08c..b9e2129 100644 --- a/components/blog/relatedContent/index.js +++ b/components/blog/relatedContent/index.js @@ -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) => ( diff --git a/components/blogs/index.js b/components/blogs/index.js index 1bce97b..fb293b9 100644 --- a/components/blogs/index.js +++ b/components/blogs/index.js @@ -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} diff --git a/components/blogs/latestArticles/Article.js b/components/blogs/latestArticles/Article.js index c887fc5..3101ddb 100644 --- a/components/blogs/latestArticles/Article.js +++ b/components/blogs/latestArticles/Article.js @@ -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]"> diff --git a/components/blogs/latestArticles/index.js b/components/blogs/latestArticles/index.js index c910eb6..9d2970d 100644 --- a/components/blogs/latestArticles/index.js +++ b/components/blogs/latestArticles/index.js @@ -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> diff --git a/components/blogs/title/ItemTitle.js b/components/blogs/title/ItemTitle.js index ccc7b10..5f3c63f 100644 --- a/components/blogs/title/ItemTitle.js +++ b/components/blogs/title/ItemTitle.js @@ -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" > diff --git a/components/blogs/title/index.js b/components/blogs/title/index.js index a70c325..11cfe2d 100644 --- a/components/blogs/title/index.js +++ b/components/blogs/title/index.js @@ -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} /> ))} diff --git a/services/response.js b/services/response.js index 6988f09..4d52537 100644 --- a/services/response.js +++ b/services/response.js @@ -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,