WhatsNew
آخرین تغییرات سامانه — زنگوله، پنل تاریخچه و مودال ریلیزهای بزرگ، با تاریخ شمسی
کدام سطح را میخواهید؟
اعلام تغییرات محصول تصمیم میگیرد کدام ریلیز سزاوار کدام سطح است و بودجهٔ وقفه را
تعیین میکند — از آنجا شروع کنید. WhatsNew* زنگوله و پنل و مودال است،
ReleaseTimeline صفحهٔ کامل، و useWhatsNew حالت
«دیدهشده».
معرفی
WhatsNew* خانوادهای از چند کامپوننت و یک هوک است که «آخرین تغییرات» را در پوستهٔ هر محصول نشان
میدهد: WhatsNewBell (تریگر با نشانه)، WhatsNewPanel (پنل تاریخچهٔ کامل) و
WhatsNewSpotlight (مودال، فقط برای ریلیزهای بزرگ). منطق «دیدهشده» در هوک useWhatsNew زندگی
میکند و یکبار در بالای همهٔ آنها صدا زده میشود.
چه زمانی استفاده کنیم
- در هدر یا
headerEndپوستهٔ برنامه، تا کاربر بفهمد سامانه بهروز شده است - وقتی یادداشتهای انتشار در خودِ ریپوی محصول نگهداری میشوند (یک ماژول TypeScript، نه یک فایل در
public/) - وقتی میخواهید کاربر علاوه بر آخرین ریلیز، تاریخچهٔ قبلی را هم ببیند
چه زمانی استفاده نکنیم
- برای اعلانهای کاری کاربر (تحلیل شما تمام شد، خطا رخ داد) — آن
NotificationCenterاست - برای پیام تکباره و فوری روی یک صفحهٔ خاص —
Bannerمناسبتر است - برای تغییرات داخلی و مهندسی که کاربر آنها را نمیبیند؛ چنین موردی اصلاً یادداشت نمیخواهد
دو تغییر خواندهنشده دارید — زنگوله را بزنید
استفاده
کامپوننتهای این خانواده همگی از یک هوک تغذیه میشوند. هوک را در دو جا صدا نزنید — دو نمونه یعنی دو واترمارک مستقل، و آنوقت «خوانده شد» در پنل، نشانهٔ روی زنگوله را خاموش نمیکند.
'use client'
import * as React from 'react'
import Link from 'next/link'
import { WhatsNewBell, WhatsNewPanel, WhatsNewSpotlight, useWhatsNew } from '@partodata/ui'
import { whatsNewFeed } from '@/content/whats-new'
export function WhatsNewTrigger() {
const [open, setOpen] = React.useState(false)
const feed = useWhatsNew({ notes: whatsNewFeed.notes, productKey: whatsNewFeed.product })
return (
<>
<WhatsNewBell unseenCount={feed.announcedCount} onClick={() => setOpen(true)} />
<WhatsNewPanel
open={open}
onOpenChange={setOpen}
notes={feed.notes}
unseenIds={feed.unseen.map((note) => note.id)}
onMarkAllSeen={feed.markAllSeen}
productName="پایش"
viewAllHref="/whats-new"
linkComponent={Link}
/>
<WhatsNewSpotlight
note={feed.spotlight}
onSeen={feed.markSpotlightShown}
productName="پایش"
linkComponent={Link}
/>
</>
)
}فایل محتوا یک ماژول ساده است:
import { defineReleaseFeed } from '@partodata/ui/release-notes'
export const whatsNewFeed = defineReleaseFeed({
product: 'profiling',
notes: [
{
id: 'saved-filters',
date: '2026-07-14',
channel: 'announce',
title: 'فیلترهای پرکاربردتان را ذخیره کنید',
summary: 'هر ترکیبی از فیلترها را نامگذاری کنید تا دفعهٔ بعد با یک کلیک اعمال شود.',
changes: [
{ type: 'feature', text: 'ذخیره و نامگذاری فیلترها' },
{ type: 'fix', text: 'بازهٔ تاریخ پس از تازهسازی صفحه دیگر پاک نمیشود' },
],
},
],
})حتماً از defineReleaseFeed استفاده کنید
یک شیء ساده با تایپ ReleaseFeed هم کامپایل میشود، ولی آنوقت هیچ اعتبارسنجیای اجرا نمیشود — آرایهٔ نامرتب،
شناسهٔ تکراری، تاریخِ بیش از یک روز آینده یا دومین مودال در یک ماه بیصدا منتشر میشوند. defineReleaseFeed همان شیء
را برمیگرداند و فقط هنگام ارزیابی ماژول بررسیاش میکند.
چرا ماژول TypeScript و نه یک فایل در public؟
چون آنوقت شناسهٔ جدیدترین یادداشت در زمان build معلوم است، تایپچک میشود و هیچ درخواست شبکهای لازم نیست. نسخهٔ فعلی
این الگو در یکی از محصولات، یک فایل version.json را با localStorage مقایسه میکرد؛ فایل از نسخهٔ واقعی عقب افتاد و
آن ریلیز برای هیچکس باز نشد.
شکل یک یادداشت
هر ورودی فید یک ReleaseNote است. چهار فیلد اجباریاند و بقیه اختیاری:
ReleaseNoteChange.type یکی از feature · improvement · fix · breaking است — عمداً چهار
تا، درحالیکه changelog توسعهدهندهٔ خودِ دیزاینسیستم هفت نوع دارد. اپراتورِ یک محصول با «نگهداری»
یا «مستندات» کاری ندارد.
حالتها و انواع
سه کانال انتشار
channel اجباری است و پیشفرض ندارد — نویسندهٔ هر یادداشت باید تصمیم بگیرد، وگرنه build نمیگذرد.
silentدر تاریخچه ثبت میشود و هیچ نشانهای روشن نمیکند. انتخاب پیشفرض.announceنشانه را روی زنگوله روشن میکند.spotlightعلاوه بر نشانه، یکبار مودال باز میکند. سقف: ماهی یکی.بسامد محتوا از بسامد اعلان جداست: همهٔ ریلیزها در پنل ثبت میشوند، ولی فقط موارد گزیده نشانه را روشن میکنند. اگر هر ریلیز نشانه روشن کند، نشانه بیمعنا میشود و کاربر آن را نادیده میگیرد.
نشانه: نقطه یا شمارنده
پیشفرض dot است، نه شمارنده. عدد یعنی صفی که باید خالی شود؛ نقطه یعنی چیز خوبی منتشر شده است.
پیشفرض dot است. عدد پیام «صفی هست که باید خالی شود» میدهد که برای اعلان درست است و برای خبر خوب
نه. count را فقط وقتی به کار ببرید که واقعاً چند خبر مستقل انباشته شده باشد. بالای ۹ به «۹+»
خلاصه میشود تا نشانه از اندازهٔ دکمه بیرون نزند:
دو تغییر خواندهنشده دارید — زنگوله را بزنید
مودال ریلیزهای بزرگ
مودال فقط برای ریلیزهای بزرگ است و حداکثر ماهی یکبار. بهمحض نمایش، «دیدهشده» علامت میخورد — نه هنگام بستن.
WhatsNewSpotlight هرگز انباشته نمیشود: اگر دو یادداشت spotlight دیدهنشده باشند، هوک فقط
جدیدترین را برمیگرداند. در اولین بازدید هم چیزی باز نمیشود.
در مینیاپ پیامرسان مودال نگذارید
مودال تمامصفحه داخل WebView تلگرام با ژست بازگشت درگیر میشود. چون WhatsNewSpotlight کامپوننت جداگانهای است، در آن
پوسته کافی است اصلاً رندرش نکنید؛ زنگوله و پنل سر جایشان میمانند.
حالت «دیدهشده»
هوک بهصورت پیشفرض در localStorage با کلید parto:whats-new:<productKey> مینویسد و کلید
«دیدهشده» شناسهٔ یادداشت است، نه شمارهٔ نسخه.
- مبتنی بر ترتیب است، نه برابری. «دیدهنشده» یعنی یادداشتهایی که در آرایه بالاتر از واترمارک هستند. پس آرایه باید همیشه از جدید به قدیم مرتب باشد.
- اولین بازدید سکوت است. بدون مقدار ذخیرهشده، جدیدترین شناسه نوشته میشود و چیزی نشان داده نمیشود.
- بازیابی دومرحلهای. اگر شناسهٔ ذخیرهشده دیگر وجود نداشته باشد، از
seenAtاستفاده میشود؛ و فقط اگر آن هم نشد، همهچیز خواندهشده علامت میخورد. - دو قاعدهٔ متفاوت برای تاریخِ آینده، عمداً. اعتبارسنج یک روز حاشیه میدهد چون ساعتِ ماشینِ build را میخواند و یادداشتِ «امروزِ تهران» روی رانر UTC هنوز «فردا» است؛ ولی رندر هر یادداشتِ بعد از امروز را کنار میگذارد. یعنی یادداشت فردا build را نمیشکند و در عین حال زودتر هم دیده نمیشود.
- واترمارک خودبهخود جلو نمیرود، و مودال هم آن را حرکت نمیدهد. فقط باز کردن پنل واترمارک را جلو میبرد؛ نمایش مودال یک شناسهٔ جداگانه ثبت میکند تا همان مودال دوباره باز نشود.
برای همگامسازی بین دستگاهها، هوک را کنترلشده اجرا کنید؛ آنوقت هیچ چیزی در localStorage نوشته
نمیشود:
const feed = useWhatsNew({
notes: whatsNewFeed.notes,
lastSeenId: user.whatsNewSeenId,
onSeen: (id) => saveSeenId(id),
lastSpotlightId: user.whatsNewSpotlightId,
onSpotlightShown: (id) => saveSpotlightId(id),
})هر چهار مورد را با هم بدهید. در حالت کنترلشده هوک اصلاً به localStorage دست نمیزند، پس اگر
فقط نیمهٔ واترمارک را وصل کنید، شناسهٔ مودالِ نمایشدادهشده تنها تا پایان همان بارگذاری صفحه
زنده میماند و مودال با هر بار refresh دوباره باز میشود.
ادبیات یادداشتها
عنوان میگوید کاربر حالا چه میتواند بکند — نه اینکه چه چیزی پیادهسازی شد.
| بهجای این | این را بنویسید |
|---|---|
| ❌ افزوده شدن قابلیت پاسخ گروهی | ✅ حالا میتوانید به چند کامنت همزمان پاسخ دهید |
| ❌ نسخهٔ ۲.۴.۰ منتشر شد | ✅ گزارشها را میتوانید زمانبندی کنید |
| ❌ رفع باگ در سرویس همگامسازی پروفایل | ✅ آمار پروفایلها دیگر با تأخیر بهروز نمیشود |
| ❌ بهبود عملکرد کوئریهای پسزمینه | ✅ داشبورد سریعتر باز میشود |
قواعد کوتاه: عنوان حداکثر ۶۰ نویسه و خلاصه حداکثر ۲۰۰ نویسه · یک عنوان، یک ایده (سه چیز یعنی یک یادداشت با سه بولت) · بدون اصطلاح داخلی و بدون نام کلاس و سرویس · فارسی رسمی · رفع اشکالها آراماند و تأکید نمیگیرند.
راهنمای استفاده
بکنید
- هوک را یکبار و در بالاترین نقطه صدا بزنید و مقدارهای مشتقشده را به کامپوننتها بدهید - آرایه را همیشه از جدید به
قدیم مرتب نگه دارید -
idرا پس از انتشار تغییر ندهید؛ همان شناسه، حافظهٔ کاربر است -channelرا برای هر یادداشت آگاهانه انتخاب کنید و پیشفرضsilentرا جدی بگیرید - در محصول سفیدبرچسب،productNameرا از تنظیمات برند بگیرید نه از یک رشتهٔ ثابت
نکنید
- یادداشتها را از تاریخچهٔ گیت تولید نکنید؛ ارزش این سطح کاملاً در گزینش است - شمارهٔ نسخه را تیتر نکنید -
spotlightرا بیش از ماهی یکبار به کار نبرید - فایل محتوا را درpublic/نگذارید - تاریخ شمسی را درtoPersianDigitsنپیچید (پایین را ببینید)
تاریخ و ارقام
تاریخها با formatJalaliDate قالببندی میشوند و خروجیاش ارقام لاتین دارد. فونت با ویژگی
ss01 همان ارقام را فارسی نشان میدهد، درحالیکه کدپوینتها لاتین میمانند — پس کپی/پیست،
صفحهخوان و Ctrl+F سالم میمانند.
// ❌ تبدیل در جاوااسکریپت — بدون هیچ تفاوت بصری، کپی/پیست و جستوجو را میشکند
<time>{toPersianDigits(formatJalaliDate(date, 'd MMMM yyyy'))}</time>// ✅ رشتهٔ خام را بدهید و بگذارید فونت کارش را بکند
<time dateTime={note.date}>{formatJalaliDate(date, 'd MMMM yyyy')}</time>اگر سطحی دارید که فونت پرتو را بارگذاری نمیکند (خروجی PDF، بوم گرافیکی)، از پراپ formatDate
استفاده کنید.
Props
WhatsNewBell
بقیهٔ propهای <button> روی عنصر ریشه پخش میشوند و ref هم به همان میرسد.
WhatsNewPanel
WhatsNewSpotlight
useWhatsNew
خروجی هوک: notes (منتشرشدهها) · unseen · announced · announcedCount · hasUnseen ·
spotlight · lastSeenId · isFirstVisit · isReady · markSeen · markAllSeen ·
markSpotlightShown. شرح کامل هر کدام در صفحهٔ هوک آمده.
دو موردش خلاف انتظار است و ارزش گفتن دارد: unseen شامل یادداشتهای silent هم هست (آنها
در پنل نشان «جدید» میگیرند ولی زنگوله را روشن نمیکنند — برای همین announcedCount را به
WhatsNewBell بدهید، نه unseen.length)، و announced مودالی را که همین حالا نشان داده شده
حذف میکند (یادداشتی که در مودال دیده شده دیگر نباید روی زنگوله هم مطالبهٔ توجه کند، ولی چون
هنوز بالای واترمارک است در پنل «جدید» میماند).
مودال را با markSpotlightShown علامت بزنید، نه markSeen
markSeen واترمارک را جلو میبرد، یعنی هر یادداشت قدیمیتر را هم خواندهشده علامت میزند — درحالیکه مودال فقط
یکی از آنها را نشان داده است. در فهرستی مثل [مودال، اعلان، …] که هر دو دیدهنشدهاند، این کار اعلانی را که کاربر هرگز
ندید بیصدا مصرف میکرد. markSpotlightShown فقط ثبت میکند که کدام مودال نمایش داده شده و به واترمارک دست نمیزند.
دسترسیپذیری
- نام دسترسیپذیری زنگوله وقتی چیزی دیدهنشده باشد شامل تعداد است («۲ تغییر خواندهنشده»)، وگرنه
«آخرین تغییرات» خوانده میشود. خودِ نشانه
aria-hiddenاست تا دوبار خوانده نشود. - پنل روی
Sheet(Radix Dialog) ساخته شده: فوکوس داخل آن حبس میشود،Escapeمیبندد و عنوان بهaria-labelledbyوصل است. - هر گروه تغییر یک
role="group"باaria-labelبرابر نام همان گروه است، پس صفحهخوان «قابلیت جدید» و «رفع اشکال» را جدا اعلام میکند. عمداًsectionنیست: یکsectionبرچسبدار یک لندمارک است و این نشانهگذاری بهازای هر نوع تغییر در هر یادداشت تکرار میشود. - تاریخها در
<time dateTime>با مقدار میلادی میآیند، پس ابزارهای کمکی تاریخ ماشینخوان دارند درحالیکه کاربر تاریخ شمسی میبیند. WhatsNewSpotlightدر زمان نمایش ثبت میشود (نه هنگام بستن)، بنابراین کاربری که باEscapeمیبندد دوباره با همان مودال روبهرو نمیشود. آنچه ثبت میشود شناسهٔ همان مودال است، نه واترمارکِ «دیدهشده» — وگرنه اعلانهای قدیمیترِ دیدهنشده هم بیصدا مصرف میشدند.
کامپوننتهای مرتبط
- ReleaseTimeline — همین داده در قالب صفحهٔ کامل؛ اگر کاربر روی «تاریخچهٔ کامل تغییرات» کلیک کرد، مقصدش این است
- NotificationCenter — اگر پیام دربارهٔ کارِ کاربر است (تحلیل تمام شد، خطا خورد) آن را به کار ببرید؛ این دو باید کنار هم زندگی کنند نه اینکه یکی جای دیگری بنشیند
- Banner — برای یک پیام موقت و مربوط به همان صفحه، نه تاریخچهٔ محصول
- AppShell — تریگر را به اسلات
headerEndآن بدهید
ReleaseTimeline
صفحهٔ کامل «آخرین تغییرات» — همان یادداشتهای انتشار روی خط زمان، گروهبندیشده بر حسب روز شمسی
ویزارد کار (JobWizard)
wizard چندمرحلهای canonical برای راهاندازی کارهای async — تحلیل، کمپین، ارزیابی، ایمپورت — با per-step validation، draft persistence، و finish state (submitting/success/error).