- Introduced a new script `make-blog-cover.mjs` to create a default cover image for blog posts. - The image is generated in PNG format with dimensions 1200x630, suitable for Open Graph. - Utilizes the site's branding colors and logo from `public/nobat724.svg`. - Includes custom font styling using the Vazirmatn font. - The generated image is saved to `public/assets/images/blog-default-cover.png`.
361 lines
22 KiB
Markdown
361 lines
22 KiB
Markdown
# سینک کامل صفحهٔ بلاگ با بکاند + تصویر پیشفرض برند + باطلسازی کش
|
||
|
||
## پروژه
|
||
|
||
`nobat724_front` (سایت عمومی)
|
||
|
||
پرامپت همتا (cross-repo، **اول اجرا شود**): `clinicpro/.claude/prompt/blog-admin-edit-and-dark-mode.md` — اندپوینت ادمین و فراخوانندهٔ webhook آنجا ساخته میشود؛ **مصرفکنندهٔ** webhook اینجاست.
|
||
|
||
## زمینه
|
||
|
||
کاربر گزارش داده صفحهٔ بلاگ سایت (`https://nobat724.com/blog/<uuid>`) همهٔ اطلاعاتی را که در پنل ادمین مدیریت میشود نشان نمیدهد، و مقالهٔ بدون تصویر شاخص با تصویر پیشفرض نامناسب نمایش داده میشود.
|
||
|
||
بررسی کد نشان میدهد بکاند در `Blog::toArray()` این فیلدها را برمیگرداند:
|
||
|
||
```
|
||
uuid, title, slug, summary, body, image_url, tags, sources, status,
|
||
review_status, reviewer, reviewed_at, review_note, topic_slug,
|
||
meta_title, meta_description, primary_keyword, secondary_keywords, faq,
|
||
internal_links, external_links, reading_time, canonical_url, og_image,
|
||
representation, author, city, created_at, updated_at
|
||
```
|
||
|
||
اما صفحهٔ بلاگ فقط `title`، `author`، `created`، `tag[0]` (در بردکرامب)، `city` و `body` را رندر میکند.
|
||
|
||
## مشکل / هدف
|
||
|
||
### مشکل ۱ — فیلدهایی که رندر نمیشوند
|
||
|
||
| فیلد بکاند | وضعیت فعلی در سایت |
|
||
|---|---|
|
||
| `summary` (خلاصه) | ❌ فقط در متادیتا؛ در HTML صفحه هیچجا نیست |
|
||
| `tags` | ⚠️ فقط تگ اول در بردکرامب (`components/blog/head/index.js:5`)؛ فهرست تگها نیست |
|
||
| `reading_time` | ❌ رندر نمیشود |
|
||
| `sources` (منابع، سیگنال E-E-A-T) | ❌ رندر نمیشود |
|
||
| `faq` | ⚠️ فقط داخل JSON-LD (`app/blog/[slug]/page.js:173`)؛ در HTML قابلمشاهده نیست — گوگل برای rich result نیازمند حضور مرئی همان پرسش/پاسخهاست، پس FAQPage فعلی در معرض بیاعتبار شدن است |
|
||
| `internal_links` / `external_links` | ❌ رندر نمیشود |
|
||
| `status` | ✅ درست: پستِ غیرمنتشر از بکاند ۴۰۴ میگیرد |
|
||
| `slug` / `meta_*` / `canonical_url` / `og_image` | ✅ در `generateMetadata` مصرف میشود |
|
||
|
||
**نکتهٔ خلاف درخواست کاربر:** بلاگ در بکاند **دستهبندی (category) ندارد**؛ فقط ستون json `tags`. پس «دستهبندیها» با نمایش تگها پوشش داده میشود. ساخت Entity دستهبندی تسک جدا و migration جدا میخواهد و در محدودهٔ این پرامپت نیست.
|
||
|
||
### مشکل ۲ — تصویر پیشفرض
|
||
|
||
سه fallback ناهماهنگ و نامناسب در کد وجود دارد و در صفحهٔ جزئیات اصلاً fallback ای نیست:
|
||
|
||
`components/blog/detail/Caption.js:7,10`
|
||
```jsx
|
||
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||
...
|
||
{cover && cover.trim() !== "" && (
|
||
```
|
||
→ مقالهٔ بدون تصویر **هیچ تصویری** ندارد.
|
||
|
||
`components/blogs/latestArticles/Article.js:8-10`
|
||
```jsx
|
||
const imageSrc = data.images?.[0]?.url
|
||
? imageUrl(data.images[0].url, "/assets/images/cover-blog-1.png")
|
||
: "/assets/images/cover-blog-1.png";
|
||
```
|
||
|
||
`components/blog/relatedContent/Item.js:8-10`
|
||
```jsx
|
||
const cover = data?.images?.[0]?.url
|
||
? imageUrl(data.images[0].url)
|
||
: "/assets/images/cover-blog-1.png";
|
||
```
|
||
|
||
`app/blog/[slug]/page.js:16`
|
||
```js
|
||
const FALLBACK_IMG = "/assets/images/og-image.png";
|
||
```
|
||
|
||
و پیشفرضِ خودِ هلپر یک آواتار انسان است (`helper/index.js`):
|
||
```js
|
||
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
||
```
|
||
یعنی هر جای دیگری که `imageUrl(blog.image_url)` بدون آرگومان دوم صدا زده شود، عکسِ «کاربر» را برای مقاله نشان میدهد.
|
||
|
||
### مشکل ۳ — تأخیر تا یک ساعت در نمایش تغییرات ادمین
|
||
|
||
`app/blog/[slug]/page.js:21-32`
|
||
```js
|
||
const getBlog = cache(async (slug, cityId) => {
|
||
const query = cityId != null ? `?city_id=${cityId}` : "";
|
||
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
|
||
next: { revalidate: 3600, tags: [`blog-${slug}`] },
|
||
});
|
||
```
|
||
tag تعریف شده ولی **هیچکس آن را باطل نمیکند** (`app/api/` فقط `auth` دارد). پس تغییر ادمین تا ۶۰ دقیقه دیده نمیشود.
|
||
|
||
### مشکل ۴ — استایل محتوای CKEditor حذف میشود
|
||
|
||
`lib/sanitize.js` تگهای `figure`/`figcaption` را در `ALLOWED_TAGS` ندارد و `class`/`style` را هم در `ALLOWED_ATTR` ندارد. خروجی جدول/تصویر CKEditor به شکل `<figure class="table"><table>…</table></figure>` است؛ DOMPurify با `KEEP_CONTENT` پیشفرض، محتوای داخلی را نگه میدارد ولی wrapper و کلاسها را حذف میکند → جدول و تصویرِ داخل متن بدون هیچ استایلی و چسبیده رندر میشوند. (این حذف داده نیست، حذف ساختار/استایل است — واقعیت را همینطور گزارش کن.)
|
||
|
||
## معیار پذیرش
|
||
|
||
- ✅ **موفق:**
|
||
- در `/blog/<slug>` یک مقالهٔ کامل: خلاصه، فهرست تگها، زمان مطالعه، تصویر شاخص، متن، بخش «سؤالات متداول» مرئی، بخش «منابع» با لینکهای `sources` — همه رندر میشوند و مقادیرشان دقیقاً با پاسخ `GET /api/v1/blog/{slug}` یکی است.
|
||
- مقالهٔ بدون `image_url` در صفحهٔ جزئیات، کارتهای لیست، مقالات مرتبط و OG/Twitter همگی **همان یک** تصویر پیشفرض برنددار را نشان میدهند.
|
||
- `POST /api/revalidate` با هدر صحیح → `200 {"revalidated":true}` و بلافاصله پس از آن، صفحهٔ بلاگ محتوای جدید را نشان میدهد (بدون انتظار یکساعته).
|
||
- `npm run build` و `npm run lint` سبز.
|
||
- ❌ **خطا:**
|
||
- `POST /api/revalidate` بدون هدر یا با سکرت غلط → `401` و هیچ باطلسازیای انجام نشود.
|
||
- `POST /api/revalidate` با بدنهٔ نامعتبر (بدون `tags` یا `tags` غیرآرایه) → `400`.
|
||
- اسلاگ ناموجود → همان `notFound()` فعلی (۴۰۴)، بدون خطای رندر.
|
||
- ⚠️ **مرزی:**
|
||
- مقالهای با `faq: []` و `sources: []` و `tags: []` → هیچ سکشن خالی یا هدینگ بیمحتوا رندر نشود (نه «سؤالات متداول» خالی، نه `<ul>` خالی).
|
||
- مقالهای که `reading_time = null` دارد → برچسب زمان مطالعه نمایش داده نشود.
|
||
- مقالهٔ شهریافته روی دامنهٔ شهرِ دیگر → همچنان ۴۰۴ (رفتار فعلی `domainScopeCityId` نباید تغییر کند).
|
||
- `image_url` مطلق (`https://…`) → `imageUrl()` باید دستنخورده برگرداند؛ `/uploads/...` → با `NEXT_PUBLIC_API_URL` پیشوند بخورد.
|
||
- `og_image` وقتی ست است بر `image_url` مقدم بماند (رفتار فعلی `page.js:75-79` حفظ شود).
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش |
|
||
|---|---|
|
||
| `nobat724_front/helper/index.js` | ثابت `BLOG_FALLBACK_IMG` + استفاده در `normalizeBlog` |
|
||
| `nobat724_front/components/blog/detail/Caption.js` | کاور با fallback + خلاصه + استایل محتوا |
|
||
| `nobat724_front/components/blog/head/index.js` | فهرست تگها + زمان مطالعه |
|
||
| `nobat724_front/components/blog/detail/Faq.js` | **جدید** — FAQ مرئی |
|
||
| `nobat724_front/components/blog/detail/Sources.js` | **جدید** — منابع |
|
||
| `nobat724_front/components/blog/detail/index.js` | چیدن کامپوننتهای جدید |
|
||
| `nobat724_front/components/blogs/latestArticles/Article.js`, `components/blog/relatedContent/Item.js` | یکسانسازی fallback |
|
||
| `nobat724_front/app/blog/[slug]/page.js` | `FALLBACK_IMG` مشترک + tag های کش |
|
||
| `nobat724_front/app/api/revalidate/route.js` | **جدید** — webhook باطلسازی |
|
||
| `nobat724_front/lib/sanitize.js` | افزودن `figure`/`figcaption` |
|
||
| `nobat724_front/app/globals.css` | استایل محتوای مقاله (`.blog-content`) |
|
||
| `nobat724_front/public/assets/images/blog-default-cover.png` | **جدید** — تصویر پیشفرض ۱۲۰۰×۶۳۰ |
|
||
|
||
## وضعیت فعلی
|
||
|
||
`nobat724_front/components/blog/detail/Caption.js` (کل فایل):
|
||
```jsx
|
||
import React from "react";
|
||
import Image from "next/image";
|
||
import { sanitizeHtml } from "@/lib/sanitize";
|
||
import { imageUrl } from "@/helper";
|
||
|
||
function Caption({ data }) {
|
||
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||
|
||
return (
|
||
<>
|
||
{cover && cover.trim() !== "" && (
|
||
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
|
||
<Image src={cover} alt={data?.title || "blog cover"} fill className="object-cover" />
|
||
</div>
|
||
)}
|
||
{data?.body?.value && (
|
||
<div
|
||
className="my-[12px] sm:my-[16px] md:my-[20px] lg:my-[24px] text-[#525252] text-[14px] md:text-[15px] lg:text-[16px] font-normal leading-[26px] sm:leading-[28px] md:leading-[30px] lg:leading-[32px]"
|
||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(data.body.value) }}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default Caption;
|
||
```
|
||
|
||
`nobat724_front/components/blog/detail/index.js` (کل فایل):
|
||
```jsx
|
||
import Caption from "./Caption";
|
||
import SocialMedia from "./SocialMedia";
|
||
import CommentUser from "./commentUser";
|
||
|
||
function Detail({ data }) {
|
||
return (
|
||
<div className="w-full lg:w-[68%]">
|
||
<Caption data={data} />
|
||
<SocialMedia />
|
||
{/* <CommentUser data={data} loading={false} /> */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`nobat724_front/helper/index.js` (نرمالساز + هلپر تصویر):
|
||
```js
|
||
export const normalizeBlog = (blog) => {
|
||
if (!blog) return null;
|
||
return {
|
||
...blog,
|
||
images: blog.image_url ? [{ url: blog.image_url }] : [],
|
||
tag: Array.isArray(blog.tags)
|
||
? blog.tags.map((t) => (typeof t === "string" ? { name: t } : t))
|
||
: [],
|
||
created: blog.created_at ?? blog.created ?? null,
|
||
body: blog.body && typeof blog.body === "object" ? blog.body : { value: blog.body ?? "" },
|
||
author: blog.author && typeof blog.author === "object" ? blog.author.name : blog.author ?? null,
|
||
};
|
||
};
|
||
|
||
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
||
if (!url) return fallback;
|
||
if (/^https?:\/\//.test(url)) return url;
|
||
if (url.startsWith("/uploads")) {
|
||
const base = process.env.NEXT_PUBLIC_API_URL || "";
|
||
return `${base}${url}`;
|
||
}
|
||
return url;
|
||
};
|
||
```
|
||
|
||
`nobat724_front/lib/sanitize.js` (بخش مرتبط):
|
||
```js
|
||
return DOMPurify.sanitize(html, {
|
||
ALLOWED_TAGS: [
|
||
"p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
|
||
"h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
|
||
"table", "thead", "tbody", "tr", "td", "th",
|
||
],
|
||
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
|
||
ALLOW_DATA_ATTR: false,
|
||
});
|
||
```
|
||
|
||
## وظایف
|
||
|
||
### ۱. ساخت تصویر پیشفرض برنددار
|
||
|
||
`sharp@0.34.5` از قبل در `package.json` هست. یک اسکریپت یکبارمصرف در `scripts/make-blog-cover.mjs` بنویس که از `public/assets/images/logo.png` روی پسزمینهٔ برند، فایل `public/assets/images/blog-default-cover.png` با ابعاد **۱۲۰۰×۶۳۰** (نسبت استاندارد Open Graph، سازگار با `aspect-video`) بسازد:
|
||
|
||
```js
|
||
import sharp from "sharp";
|
||
|
||
const W = 1200, H = 630;
|
||
// رنگ برند را از app/globals.css یا tailwind.config برداشت کن — هاردکد نکن اگر توکن موجود است.
|
||
const BG = { r: 0x0f, g: 0x4c, b: 0x81, alpha: 1 };
|
||
|
||
const logo = await sharp("public/assets/images/logo.png").resize({ width: 420 }).toBuffer();
|
||
await sharp({ create: { width: W, height: H, channels: 4, background: BG } })
|
||
.composite([{ input: logo, gravity: "center" }])
|
||
.png()
|
||
.toFile("public/assets/images/blog-default-cover.png");
|
||
```
|
||
|
||
خروجی PNG باید کمیت شود (commit) و اسکریپت هم بماند تا قابل بازتولید باشد. اگر رنگ برند در CSS پیدا نشد، از رنگ لوگو نمونه بگیر و در گزارش بگو کدام مقدار استفاده شد.
|
||
|
||
**نحوه تست:** `node scripts/make-blog-cover.mjs` → فایل ساخته شود؛ `sips -g pixelWidth -g pixelHeight public/assets/images/blog-default-cover.png` → `1200 × 630`. تصویر را باز کن و در گزارش بگو لوگو خوانا و مرکز است.
|
||
|
||
### ۲. یک منبع واحد برای fallback تصویر بلاگ
|
||
|
||
در `helper/index.js`:
|
||
|
||
```js
|
||
// تصویر پیشفرض همهٔ مقالهها — لیست، کارت، جزئیات، OG و توییتر همگی همین را
|
||
// میگیرند تا هیچ مقالهای بدون تصویر (یا با آواتار کاربر) نمایش داده نشود.
|
||
export const BLOG_FALLBACK_IMG = "/assets/images/blog-default-cover.png";
|
||
|
||
export const blogCover = (blog) =>
|
||
imageUrl(blog?.og_image || blog?.image_url || blog?.images?.[0]?.url, BLOG_FALLBACK_IMG);
|
||
```
|
||
|
||
سپس جایگزینی در همهٔ نقاط مصرف:
|
||
- `components/blog/detail/Caption.js` → `const cover = blogCover(data);` و شرط `{cover && ...}` حذف شود (همیشه تصویر هست).
|
||
- `components/blogs/latestArticles/Article.js:8-10` → `const imageSrc = blogCover(data);`
|
||
- `components/blog/relatedContent/Item.js:8-10` → `const cover = blogCover(data);`
|
||
- `app/blog/[slug]/page.js:16,75-79` → `FALLBACK_IMG` حذف و از `BLOG_FALLBACK_IMG` استفاده شود؛ منطق تقدم `og_image` روی `image_url` داخل `blogCover` است، پس بلوک شرطی سهطبقهٔ فعلی ساده میشود.
|
||
- ارجاعهای `cover-blog-1.png` / `user.png` برای بلاگ حذف شوند. (فایلهای `head-blogs-*.png` در `components/blogs/Head.js` هستند که **کد مرده است** — کل کامپوننت `return null` میکند و بقیهاش کامنت است؛ دست نزن، فقط در گزارش ذکر کن.)
|
||
|
||
**نحوه تست:** با `npm run dev` (روی `http://yazd-nobat.localhost:3000`) یک مقالهٔ بدون `image_url` باز کن → کاور پیشفرض دیده شود؛ `curl -s <url> | grep 'og:image'` → مسیر `blog-default-cover.png`؛ همان مقاله در `/blogs` و در «مقالات مرتبط» هم همان تصویر را داشته باشد.
|
||
|
||
### ۳. رندر فیلدهای گمشده
|
||
|
||
**سربرگ** (`components/blog/head/index.js`) — بعد از ردیف نویسنده/تاریخ:
|
||
- `data.reading_time` → «زمان مطالعه: X دقیقه» با همان تایپوگرافی `text-[#9B9B9B]`.
|
||
- فهرست کامل تگها به شکل چیپهای لینکدار (اگر صفحهٔ فیلتر تگ ندارد، به `/blogs` لینک بده یا بدون لینک رندر کن — تصمیم را در کد کامنت کن).
|
||
|
||
**خلاصه** — در `Caption.js` بالای بدنه، فقط اگر `data.summary` غیرخالی باشد:
|
||
```jsx
|
||
{data?.summary && (
|
||
<p className="mt-[16px] text-[#3B3B3B] text-[15px] md:text-[16px] font-medium leading-[30px] border-r-[3px] border-[#E5E5E5] pr-[12px]">
|
||
{data.summary}
|
||
</p>
|
||
)}
|
||
```
|
||
|
||
**FAQ مرئی** — `components/blog/detail/Faq.js` جدید، با `<details>/<summary>` بومی (بدون وابستگی جدید)، فقط وقتی حداقل یک آیتم معتبر (`f.q && f.a`) وجود دارد. همان آرایهای که در `app/blog/[slug]/page.js` به JSON-LD میرود باید اینجا هم رندر شود — تطابق متن مرئی با JSON-LD شرط اعتبار FAQPage است.
|
||
|
||
**منابع** — `components/blog/detail/Sources.js` جدید: `data.sources` آرایهای از `{url, title}` است؛ لینکها با `target="_blank" rel="nofollow noopener"`. فقط وقتی آرایه غیرخالی است رندر شود.
|
||
|
||
هر دو در `components/blog/detail/index.js` بعد از `Caption` و قبل از `SocialMedia` قرار بگیرند.
|
||
|
||
**نحوه تست:** یک مقالهٔ واقعی از API بگیر و خروجی را با صفحه مقایسه کن:
|
||
```bash
|
||
curl -s "$NEXT_PUBLIC_API_URL/api/v1/blog/<slug>" | jq '.data.data | {summary,tags,reading_time,faq,sources}'
|
||
curl -s "http://yazd-nobat.localhost:3000/blog/<slug>" | grep -c "سؤالات متداول"
|
||
```
|
||
سپس مقالهای با `faq: []` و `sources: []` باز کن → هیچ هدینگ خالی نباشد. JSON-LD صفحه را در [Rich Results Test](https://search.google.com/test/rich-results) یا با `jq` اعتبارسنجی کن.
|
||
|
||
### ۴. استایل محتوای مقاله + sanitizer
|
||
|
||
در `lib/sanitize.js` تگهای `figure` و `figcaption` به `ALLOWED_TAGS` اضافه شوند (خروجی جدول/تصویر CKEditor). **هشدار داخل همان فایل را جدی بگیر:** این فهرست آینهٔ `clinicpro-crawler/content/composer.py` است — همانجا هم بهروزرسانی و در گزارش ذکر کن.
|
||
|
||
در `app/globals.css` یک کلاس `.blog-content` تعریف کن که `h2/h3/ul/ol/table/img/blockquote/a` داخل بدنهٔ مقاله را استایل بدهد (چون `class` و `style` عمداً از HTML ورودی حذف میشوند، استایل باید از سمت سایت بیاید)، و در `Caption.js` روی همان `div` مربوط به `dangerouslySetInnerHTML` بنشیند.
|
||
|
||
**نحوه تست:** در پنل ادمین یک جدول و یک لیست و یک نقلقول در مقاله درج کن، ذخیره کن، صفحهٔ سایت را ببین: جدول با حاشیه، لیست با bullet، نقلقول با نوار کناری. اسکرینشات ضمیمه شود.
|
||
|
||
### ۵. Webhook باطلسازی کش (مصرفکنندهٔ قرارداد بکاند)
|
||
|
||
`app/api/revalidate/route.js` جدید:
|
||
|
||
```js
|
||
import { NextResponse } from "next/server";
|
||
import { revalidateTag, revalidatePath } from "next/cache";
|
||
|
||
/**
|
||
* باطلسازی on-demand کش ISR بلاگ. بکاند (clinicpro) پس از هر create/update/
|
||
* delete/review این مسیر را صدا میزند تا تغییر پنل ادمین بدون تأخیر روی سایت بیاید.
|
||
* قرارداد: POST { tags: string[] } + هدر X-Revalidate-Secret
|
||
*/
|
||
export async function POST(request) {
|
||
const secret = process.env.REVALIDATE_SECRET;
|
||
if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
|
||
return NextResponse.json({ revalidated: false }, { status: 401 });
|
||
}
|
||
|
||
const body = await request.json().catch(() => null);
|
||
const tags = body?.tags;
|
||
if (!Array.isArray(tags) || tags.length === 0) {
|
||
return NextResponse.json({ revalidated: false, error: "tags required" }, { status: 400 });
|
||
}
|
||
|
||
tags.forEach((t) => revalidateTag(String(t)));
|
||
if (tags.includes("blog-list")) revalidatePath("/blogs");
|
||
|
||
return NextResponse.json({ revalidated: true, tags });
|
||
}
|
||
```
|
||
|
||
و در `app/blog/[slug]/page.js` هر دو tag را ثبت کن تا بکاند بتواند با slug **یا** uuid باطل کند (URL سایت uuid است، ولی `blog-<slug>` هم از سمت بکاند میآید):
|
||
|
||
```js
|
||
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
|
||
```
|
||
|
||
`REVALIDATE_SECRET` را به `.env.example`/مستند محیط اضافه کن؛ مقدارش باید با `REVALIDATE_WEBHOOK_SECRET` سمت `clinicpro` یکی باشد.
|
||
|
||
**نحوه تست:**
|
||
```bash
|
||
npm run build && npm run start
|
||
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/revalidate # → 401
|
||
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
|
||
-H 'Content-Type: application/json' -d '{"tags":["blog-list"]}' # → {"revalidated":true,...}
|
||
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
|
||
-H 'Content-Type: application/json' -d '{}' -o /dev/null -w '%{http_code}\n' # → 400
|
||
```
|
||
سپس تست انتها-به-انتها: عنوان مقاله را در `/admin/blogs` عوض کن و بلافاصله صفحهٔ سایت را رفرش کن → عنوان جدید (نه ۶۰ دقیقه بعد).
|
||
|
||
## نکات مهم
|
||
|
||
- **ترتیب اجرا:** پرامپت `clinicpro` اول. بدون آن، تست انتها-به-انتهای وظیفهٔ ۵ ممکن نیست (فراخوانندهٔ webhook آنجاست).
|
||
- بلاگ در بکاند دستهبندی ندارد؛ `tags` نقش آن را دارد. اگر کاربر واقعاً Entity دستهبندی میخواهد، تسک جدا با migration لازم است — در این پرامپت نیست و نباید سرخود ساخته شود.
|
||
- `components/blogs/Head.js` کد مرده است (`return null` + بقیه کامنت). fallbackهای `head-blogs-*.png` داخل آن هیچ اثری ندارند؛ تغییرشان ندهید، فقط در گزارش پایانی ذکر شود.
|
||
- `imageUrl()` هلپر عمومی است و جاهای دیگر (پزشک، کلینیک) روی پیشفرض `user.png` حساب میکنند؛ **امضای آن را عوض نکن** — `blogCover()` جدید فقط برای بلاگ است. (اصل باز/بسته: رفتار جدید با تابع جدید، نه با `if` روی نوع.)
|
||
- `next/image` برای دامنهٔ API نیاز به `remotePatterns` در `next.config` دارد؛ اگر کاور از `NEXT_PUBLIC_API_URL` میآید و بیلد خطا داد، پیکربندی موجود را چک کن (قبل از تغییر، `next.config.*` را بخوان).
|
||
- هیچ سکشن خالی رندر نشود — این هم معیار پذیرش مرزی است و هم مسئلهٔ SEO (هدینگ بیمحتوا).
|
||
- بعد از پیادهسازی: `npm run lint` و `npm run build` باید سبز باشند، و تغییر قرارداد کش را در `nobat724_front` README/محیط مستند کن.
|