بلاک‌ها · بانکی و پرداخت

تراکنش‌ها، خطِ زمانی

پیش‌نمایش تمام‌صفحه

تراکنش‌ها گروه‌بندی‌شده بر اساسِ روز با جمعِ خالصِ هر روز، جست‌وجو و سگمنتِ واریز/برداشت با شمارِ نتیجه، ردیفِ لمسی با آیکونِ نوع و حالتِ بی‌نتیجه با پاک‌کردنِ فیلتر.

۱. نصب کامپوننت‌ها

این بلاک از ۷ آیتم دیگ استفاده می‌کند: 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  )
}