الگوهای خطا

راهنمای نمایش خطاها در پرتو — API errors، validation، و حالت‌های سیستمی

انواع خطا

هر نوع خطا یک پاسخ دارد، و در صفحه‌های اپلیکیشن آن پاسخ را قالب صفحه می‌کشد:

نوعتوضیحالگو
خطای بارگذاری (API یا شبکه)درخواست داده‌ای که صفحه یا یک بلوکش نشان می‌دهد شکست خوردstate={pageState(…)} قالب: ErrorState با «تلاش مجدد» جای محتوا؛ سربرگ و نوارابزار می‌مانند
خطای ذخیرهٔ فرمذخیرهٔ FormPage یا SettingsSection شکست خوردerror قالب: خلاصه‌ای بالای فرم که اعلام و فوکوس می‌شود
خطای validationورودی کاربر نامعتبر استerror همان FormRow زیر فیلد
خطای مجوزکاربر اجازهٔ دیدن صفحه را نداردUtilityPage kind="403"
صفحه‌ای که فرو ریختخطای پیش‌بینی‌نشده هنگام رندرerror.tsx در Next.js با UtilityPage kind="500"

در صفحه: state قالب

خطای بارگذاری را خودتان نمی‌کشید: pageState از فیلدهای useAsync حالت را می‌سازد و قالب ErrorState را با متن DS («بارگذاری انجام نشد») و دکمهٔ «تلاش مجدد» جای محتوا می‌گذارد. سربرگ صفحه، نوارابزار و فیلترها و شمارهٔ صفحه سر جایشان می‌مانند و «تلاش مجدد» همان درخواست را دوباره می‌فرستد:

import { DataTable, useAsync } from '@partodata/ui'
import { ListPage, pageState } from '@partodata/ui/templates'

const { data, isLoading, error, run } = useAsync<Paged<Mention>>()

;<ListPage
  title="منشن‌ها"
  state={pageState({
    data: data?.items,
    isLoading,
    error,
    onRetry: load,
    emptyCopy: { title: 'هنوز منشنی ثبت نشده است' },
  })}
>
  <DataTable columns={columns} data={data?.items ?? []} />
</ListPage>

بلوکی که جدا بارگذاری می‌شود (یک بخش صفحهٔ جزئیات، یک نمودار داشبورد) همان را در state خودش می‌گیرد: DetailSection، DashboardChart یا PageState. متن خطا همیشه متن DS است؛ پیام سرور به کاربر نشان داده نمی‌شود.


نمونه بصری — حالت خطای API


ErrorState کامپوننت

در صفحه ErrorState را قالب رندر می‌کند (بالا). خودتان آن را فقط جایی بنویسید که قالبی حالت را نمی‌کشد — یک Sheet، یک پنل، محتوای یک CustomPage:

;< ="اطلاعات اینفلوئنسرها بارگذاری نشد. دوباره تلاش کنید." ={} />

دکمه تلاش مجدد را خودِ کامپوننت می‌سازد

ErrorState ویژگی description و action ندارد. رابطش title (عنوان اختیاری، با سطح titleAs)، message، onRetry، retryLabel، size و locale است، و هر فرزندی پس از دکمه نمایش داده می‌شود. با دادن onRetry دکمه‌ی تلاش مجدد خودکار ظاهر می‌شود؛ برای عوض‌کردن متنش از retryLabel استفاده کنید.


Callout برای خطاهای inline

برای شکست عملیاتی که کاربر در میان محتوا انجام داده (نه بارگذاری صفحه، که state قالب است، و نه ذخیرهٔ فرم، که error قالب فرم است):

import { Callout, CalloutTitle, CalloutDescription } from '@partodata/ui'

{
  error && (
    <Callout variant="destructive">
      <CalloutTitle>عملیات ناموفق بود</CalloutTitle>
      <CalloutDescription>{error.message || 'درخواست انجام نشد. دوباره تلاش کنید.'}</CalloutDescription>
    </Callout>
  )
}

خطای API در داشبورد

هر نمودار داشبورد حالت خودش را در state می‌گیرد: خطا جای ناحیهٔ نمودار می‌نشیند و بقیهٔ داشبورد سر جایش می‌ماند. اسکلت، کادر خطا و دکمهٔ «تلاش مجدد» را خودتان نسازید:

import { PartoAreaChart, useAsync } from '@partodata/ui'
import { DashboardChart, pageState } from '@partodata/ui/templates'

const trend = useAsync<TrendPoint[]>()

;<DashboardChart
  title="روند منشن‌ها"
  state={pageState({ data: trend.data, isLoading: trend.isLoading, error: trend.error, onRetry: loadTrend })}
>
  <PartoAreaChart data={trend.data ?? []} dataKeys={['منشن']} ariaLabel="روند منشن‌ها" />
</DashboardChart>

خطای شبکه

{
  isNetworkError && (
    <Callout variant="neutral">
      <WifiOff className="h-4 w-4" />
      <CalloutTitle>اتصال به اینترنت برقرار نیست</CalloutTitle>
      <CalloutDescription>اتصال به اینترنت برقرار نیست. اتصال را بررسی کنید و دوباره تلاش کنید.</CalloutDescription>
    </Callout>
  )
}

error.tsx در Next.js

خطای پیش‌بینی‌نشده‌ای که رندر صفحه را متوقف کرد به error.tsx می‌رسد؛ آن هم یک صفحه است و قالبش UtilityPage با kind="500" (متن پیش‌فرض DS)، با «تلاش مجدد» که reset را صدا می‌زند:

// app/(dashboard)/error.tsx
'use client'

import {  } from '@partodata/ui'
import {  } from '@partodata/ui/templates'

export default function ({  }: { : Error; : () => void }) {
  return < ="500" ={< ={}>تلاش مجدد</>} />
}

خطای بارگذاری داده (درخواستی که شکست خورد) به این‌جا نمی‌رسد: آن state قالب است (بالا).


پیام‌های خطا — اصول نوشتاری

اشتباهدرست
«خطای 500»«سرور در حال حاضر پاسخ نمی‌دهد»
«null reference exception»«اطلاعات یافت نشد»
«در صورت ادامه با پشتیبانی تماس بگیرید»«دوباره تلاش کنید. اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید»
«مجاز نیست»«برای دیدن این بخش، باید وارد حساب کاربری خود شوید»

قوانین:

  • واضح — کاربر بداند چه اتفاقی افتاده
  • قابل اقدام — کاربر بداند چه کاری بکند
  • بدون اصطلاح فنی — error code را در UI نمایش ندهید
  • فارسی رسمی — «تلاش کنید» نه «امتحان کن»

دسترسی‌پذیری

قالب‌ها این را خودشان رعایت می‌کنند: خطای بارگذاری role="alert" دارد، و خلاصهٔ خطای ذخیرهٔ فرم اعلام و فوکوس می‌شود (در هر ذخیرهٔ ناموفق دوباره). الگوی زیر برای خطایی است که بیرون از قالب خودتان نمایش می‌دهید:

// خطاها باید توسط screen reader اعلام شوند
<div role="alert" aria-live="assertive">
  {error && (
    <Callout variant="destructive">
      <CalloutDescription>{error.message}</CalloutDescription>
    </Callout>
  )}
</div>

// خطای فیلد فرم: `error` همان FormRow — ردیف پیام را خودش با role="alert" اعلام می‌کند؛ live region نسازید

بهترین روش‌ها و دام‌های رایج

  • ابتدا سطح خطا را از جدول «انواع خطا» بالای صفحه تعیین کنید، بعد کامپوننت را انتخاب کنید — خطای یک فیلد هرگز Callout سطح صفحه نمی‌شود (error همان FormRow است) و خطای کل صفحه هرگز پیام یک فیلد نمی‌شود.
  • هر خطای قابل تکرار (API، شبکه) باید دکمه «تلاش مجدد» داشته باشد — خطای بدون راه خروج، بن‌بست است.
  • خطا را همیشه در کنار isLoading مدیریت کنید: اول skeleton، بعد خطا یا داده — هرگز هم‌زمان هر دو را نمایش ندهید. در صفحه این ترتیب را pageState تعیین می‌کند؛ شرط‌های خودتان را ننویسید.

دام‌های زیر پرتکرارترین اشتباهات نمایش خطا در محصولات پرتو هستند. هر مورد شامل اشتباه، دلیل، و الگوی درست است.

رنگ hardcode برای حالت خطا

اشتباه: ساختن جعبه خطا با رنگ‌های مستقیم Tailwind مانند bg-red-50 و text-red-600 به جای توکن‌های destructive.

چرا اشتباه است: تم پیش‌فرض پرتو تیره است (:root توکن‌های dark را حمل می‌کند). رنگ‌های hardcode با تغییر تم به‌روز نمی‌شوند — پس‌زمینه روشن قرمز در تم تیره از چیدمان بیرون می‌زند و contrast مورد نیاز WCAG را نقض می‌کند. قانون no-hardcoded-colors در ESLint سیستم نیز آن را رد می‌کند.

الگوی درست: از variant="destructive" در Callout یا توکن‌های destructive استفاده کنید — در هر دو تم مقدار درست می‌گیرند:

// ❌ غلط — در تم تیره (پیش‌فرض پرتو) شکسته می‌شود
<div className="bg-red-50 border border-red-200 text-red-600 rounded-lg p-4">
  ارسال کمپین تخفیف فصلی ناموفق بود
</div>

// ✅ درست — توکن‌ها در هر دو تم کار می‌کنند
<Callout variant="destructive">
  <CalloutTitle>ارسال کمپین ناموفق بود</CalloutTitle>
  <CalloutDescription>کمپین «تخفیف فصلی» ذخیره نشد. دوباره تلاش کنید.</CalloutDescription>
</Callout>

جای‌گذاری فیزیکی آیکون و دکمه بستن

اشتباه: چیدن آیکون خطا و دکمه بستن با property‌های فیزیکی: pl-10، left-3، right-3، text-left.

چرا اشتباه است: در صفحه فارسی (RTL) متن از راست شروع می‌شود، اما left-3 آیکون را در سمت چپ نگه می‌دارد — آیکون و متن در دو سمت مخالف می‌افتند و دکمه بستن روی متن می‌نشیند. این دقیقاً همان چیزی است که قانون no-physical-css-properties جلوی آن را می‌گیرد.

الگوی درست: فقط Logical Properties — ps/pe، start/end، text-start:

// ❌ غلط — در RTL آیکون و دکمه بستن جابه‌جا می‌نشینند
<div className="relative pl-10 text-left">
  <AlertCircle className="absolute left-3 top-3 h-4 w-4" />
  <button className="absolute right-3 top-3" aria-label="بستن">×</button>
</div>

// ✅ درست — در RTL و LTR هر دو درست است
<div className="relative ps-10 text-start">
  <AlertCircle className="absolute start-3 top-3 h-4 w-4" />
  <button className="absolute end-3 top-3" aria-label="بستن">×</button>
</div>

خطای بارگذاری دست‌ساز به‌جای state قالب

اشتباه: در صفحهٔ فهرست، if (error) return <ErrorState … /> که کل صفحه (سربرگ و نوارابزار هم) را برمی‌دارد، یا یک Callout دست‌ساز با دکمهٔ «تلاش مجدد» بالای جدول، کنار isLoading و pagination خود DataTable.

چرا اشتباه است: هر صفحه خطا را به شکل دیگری نشان می‌دهد: یکی سربرگ را برمی‌دارد، یکی ردیف‌های کهنه را زیر پیام خطا نگه می‌دارد، یکی دکمه را outline می‌کند. ردیف‌هایی که به درخواست فعلی جواب نمی‌دهند هم نباید زیر پیام خطا بمانند.

الگوی درست: حالت را با pageState به قالب بدهید. قالب ErrorState را جای فهرست می‌گذارد؛ سربرگ، نوارابزار، فیلترها و شمارهٔ صفحه (در state خود صفحه) می‌مانند و «تلاش مجدد» همان صفحه را دوباره می‌گیرد:

// ❌ غلط — کل صفحه جای خود را به خطا می‌دهد، یا خطا کنار ردیف‌های کهنه می‌نشیند
if (error) return <ErrorState title="خطا در بارگذاری" />

// ✅ درست — یک state برای بارگذاری، خطا و خالی؛ جدول بدون حالت‌های خودش
<ListPage
  title="بازخورد بسته‌بندی"
  state={pageState({ data: data?.items, isLoading, error, onRetry: load, emptyCopy: { title: 'هنوز بازخوردی ثبت نشده است' } })}
  pagination={{ currentPage: page, totalPages, onPageChange: setPage, totalRows, pageSize: 25 }}
>
  <DataTable columns={columns} data={data?.items ?? []} />
</ListPage>

نمایش متن خام و فنی سرور به کاربر

اشتباه: پاس‌دادن مستقیم پیام فنی سرور به UI — خروجی چیزی مانند «Request failed with status code 502» می‌شود؛ انگلیسی، فنی، و بدون اقدام.

چرا اشتباه است: پیام سرور برای توسعه‌دهنده نوشته شده، نه کاربر فارسی‌زبان. این کار هم اصول نوشتاری بالای همین صفحه (بدون اصطلاح فنی، فارسی رسمی) را نقض می‌کند و هم ممکن است جزئیات داخلی سیستم را افشا کند.

الگوی درست: کد خطا را به پیام فارسی رسمی نگاشت کنید و برای موارد ناشناخته fallback عمومی بگذارید؛ متن فنی فقط به لاگ برود. نمایش error.message فقط وقتی مجاز است که سرور پیام فارسی کاربرپسند برگرداند — و حتی در آن حالت هم مانند بخش «Callout برای خطاهای inline» بالای صفحه، fallback فارسی کنار آن بگذارید:

// ❌ غلط — message سرور متن فنی انگلیسی است و مستقیم نمایش داده می‌شود
<CalloutDescription>{error.message}</CalloutDescription>

// ✅ درست — نگاشت به فارسی رسمی + fallback عمومی
const errorMessages: Record<string, string> = {
  NETWORK: 'اتصال به اینترنت برقرار نیست. اتصال را بررسی کنید و دوباره تلاش کنید.',
  FORBIDDEN: 'برای دیدن این بخش، باید وارد حساب کاربری خود شوید.',
  NOT_FOUND: 'اطلاعات مورد نظر یافت نشد.',
}

<CalloutDescription>
  {errorMessages[error.code] ?? 'درخواست انجام نشد. دوباره تلاش کنید.'}
</CalloutDescription>

mount شرطی ناحیه aria-live

اشتباه: رندر شرطی ناحیه aria-live همراه با خود خطا — یعنی {error && <div aria-live="assertive">...</div>}.

چرا اشتباه است: screen reader فقط تغییرات یک ناحیه live از قبل موجود را اعلام می‌کند. اگر ناحیه هم‌زمان با خطا mount شود، بسیاری از screen reader‌ها آن را نادیده می‌گیرند و کاربر نابینا متوجه شکست عملیات نمی‌شود. توجه کنید که role="alert" داخلی خود Callout هم به همین دلیل کافی نیست — چون همراه با محتوا mount می‌شود.

الگوی درست: همان الگوی بخش «دسترسی‌پذیری» بالا — wrapper همیشه در DOM باشد و فقط محتوای آن تغییر کند:

// ❌ غلط — ناحیه live همراه با خطا mount می‌شود؛ اعلام نمی‌شود
{
  error && (
    <div aria-live="assertive">
      <Callout variant="destructive">
        <CalloutDescription>ارسال بازخورد بسته‌بندی ناموفق بود</CalloutDescription>
      </Callout>
    </div>
  )
}

// ✅ درست — ناحیه live همیشه هست؛ فقط محتوا تغییر می‌کند
;<div role="alert" aria-live="assertive">
  {error && (
    <Callout variant="destructive">
      <CalloutDescription>ارسال بازخورد بسته‌بندی ناموفق بود</CalloutDescription>
    </Callout>
  )}
</div>

صفحات مرتبط