تراکنشها، خطِ زمانی
پیشنمایش تمامصفحهتراکنشها گروهبندیشده بر اساسِ روز با جمعِ خالصِ هر روز، جستوجو و سگمنتِ واریز/برداشت با شمارِ نتیجه، ردیفِ لمسی با آیکونِ نوع و حالتِ بینتیجه با پاککردنِ فیلتر.
۱. نصب کامپوننتها
این بلاک از ۷ آیتم دیگ استفاده میکند: buttoncontrol-sizeempty-stateiconspersiansearch-fieldtoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
transactions-timeline.tsx
"use client"
import * as React from "react"
import { ArrowUpIcon, ReceiptIcon, SearchIcon, ShopIcon, WalletIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { SearchField } from "@/components/ui/search-field"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatNumber, toFaDigits, toSearchKey } from "@/lib/persian"
type Tx = { id: string; day: string; title: string; note: string; amount: number; kind: "shop" | "bill" | "income" | "transfer" }
const TXS: Tx[] = [
{ id: "1", day: "امروز", title: "کافه رست", note: "۱۰:۲۴ · کارتِ ملت", amount: -185_000, kind: "shop" },
{ id: "2", day: "امروز", title: "اسنپ", note: "۰۸:۱۰ · کیف پول", amount: -96_000, kind: "shop" },
{ id: "3", day: "دیروز", title: "واریزِ حقوق", note: "۰۹:۰۰ · شرکتِ آوند", amount: 32_000_000, kind: "income" },
{ id: "4", day: "دیروز", title: "قبضِ برق", note: "۲۲:۴۰ · شناسهٔ ۹۸۲۱", amount: -412_000, kind: "bill" },
{ id: "5", day: "۱۲ مهر", title: "انتقال به سارا محمدی", note: "۱۷:۰۵ · کارتبهکارت", amount: -1_200_000, kind: "transfer" },
{ id: "6", day: "۱۲ مهر", title: "فروشگاهِ بوم", note: "۱۳:۳۰ · کارتِ ملت", amount: -740_000, kind: "shop" },
]
const ICON = { shop: <ShopIcon />, bill: <ReceiptIcon />, income: <WalletIcon />, transfer: <ArrowUpIcon className="rotate-45" /> }
const FILTERS = [
{ value: "all", label: "همه" },
{ value: "in", label: "واریز" },
{ value: "out", label: "برداشت" },
]
/** مبلغ با علامتِ همیشهچپ (bdi) و رنگِ سبز فقط برای ورودی؛ برداشت رنگِ عادی متن است، نه قرمز. */
function Amount({ value, className }: { value: number; className?: string }) {
return (
<span className={`tabular-nums ${value > 0 ? "text-success" : ""} ${className ?? ""}`}>
<bdi dir="ltr">
{value > 0 ? "+" : "−"}
{formatNumber(Math.abs(value))}
</bdi>{" "}
<span className="text-xs text-muted-foreground">تومان</span>
</span>
)
}
/**
* تراکنشها به شکلِ خطِ زمانی: گروهبندی بر اساسِ روز با جمعِ خالصِ همان روز در سرِ گروه،
* جستوجو و سگمنتِ واریز/برداشت با شمارِ نتیجه، ردیفِ کاملاً لمسی با آیکونِ نوع و زمان، و
* حالتِ بینتیجه با راهِ برگشت (پاککردنِ فیلتر).
*/
export default function TransactionsTimelineBlock() {
const [query, setQuery] = React.useState("")
const [filter, setFilter] = React.useState("all")
const visible = TXS.filter((tx) => (filter === "all" || (filter === "in" ? tx.amount > 0 : tx.amount < 0)) && toSearchKey(tx.title + tx.note).includes(toSearchKey(query)))
const days = [...new Set(visible.map((tx) => tx.day))]
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-2xl flex-col gap-8 p-4 sm:p-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold">تراکنشها</h1>
<p className="text-muted-foreground">{toFaDigits(visible.length)} تراکنش {filter !== "all" || query ? "با این فیلتر" : "در ۷ روزِ گذشته"}</p>
</header>
<div className="flex flex-col gap-3 sm:flex-row">
<SearchField aria-label="جستوجو در تراکنشها" placeholder="فروشگاه، شخص یا شناسه" value={query} onValueChange={setQuery} className="flex-1" />
<ToggleGroup type="single" variant="segmented" value={filter} onValueChange={(value) => value && setFilter(value as string)} aria-label="نوعِ تراکنش">
{FILTERS.map((item) => (
<ToggleGroupItem key={item.value} value={item.value} className="flex-1 sm:flex-none">
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{visible.length === 0 ? (
<EmptyState variant="bordered" className="rounded-xl py-12">
<SearchIcon aria-hidden className="size-8 text-placeholder" />
<EmptyStateTitle>تراکنشی پیدا نشد</EmptyStateTitle>
<EmptyStateDescription>واژهٔ کوتاهتری بنویس یا فیلتر را روی «همه» بگذار.</EmptyStateDescription>
<EmptyStateActions>
<Button
variant="outline"
onClick={() => {
setQuery("")
setFilter("all")
}}
>
پاک کردنِ فیلترها
</Button>
</EmptyStateActions>
</EmptyState>
) : (
<div className="flex flex-col gap-8">
{days.map((day) => {
const rows = visible.filter((tx) => tx.day === day)
const net = rows.reduce((sum, tx) => sum + tx.amount, 0)
return (
<section key={day} aria-labelledby={`tt-${day}`} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3 px-1">
<h2 id={`tt-${day}`} className="text-sm font-semibold text-muted-foreground">
{day}
</h2>
<Amount value={net} className="text-sm" />
</div>
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border bg-card">
{rows.map((tx) => (
<li key={tx.id}>
<a href="#" className="flex min-h-16 items-center gap-3 px-4 py-3 hover:bg-subtle">
<span aria-hidden className={`flex size-10 shrink-0 items-center justify-center rounded-full [&>svg]:size-5 ${tx.amount > 0 ? "bg-success-subtle text-success" : "bg-subtle text-muted-foreground"}`}>
{ICON[tx.kind]}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{tx.title}</span>
<span className="text-sm text-muted-foreground">{tx.note}</span>
</span>
<Amount value={tx.amount} className="font-bold" />
</a>
</li>
))}
</ul>
</section>
)
})}
</div>
)}
</main>
</ControlSizeProvider>
)
}