پرتوپرتو

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 است. چهار فیلد اجباری‌اند و بقیه اختیاری:

Prop

Type

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

Type

بقیهٔ propهای <button> روی عنصر ریشه پخش می‌شوند و ref هم به همان می‌رسد.

WhatsNewPanel

Prop

Type

WhatsNewSpotlight

Prop

Type

useWhatsNew

Prop

Type

خروجی هوک: 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 آن بدهید