نتایجِ بلیتِ اتوبوس
پیشنمایش تمامصفحهنتایجِ اتوبوس با فیلترِ کنارِ فهرست: بازهٔ ساعتِ حرکت، شرکتِ مسافربری و نوعِ اتوبوس (VIP یا معمولی)؛ کارتِ فشرده با پایانهٔ مبدأ و مقصد، صندلیِ باقیمانده و تخفیف، و حالتِ «فیلتر نتیجه نداشت» با پاککردنِ فیلترها.
۱. نصب کامپوننتها
این بلاک از ۱۶ آیتم دیگ استفاده میکند: avatarbadgebuttoncheckboxcontrol-sizeempty-statefare-date-stripiconslabelpersianselectsheetswitchticket-cardtoasttoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
ticket-results-bus.tsx
"use client"
import * as React from "react"
import { BusIcon, EditIcon, FilterIcon, LocationIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIcon, EmptyStateTitle } from "@/components/ui/empty-state"
import { FareDateStrip, type FareDay } from "@/components/ui/fare-date-strip"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { TicketCard, TicketCardDetail } from "@/components/ui/ticket-card"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatCurrency, toFaDigits } from "@/lib/persian"
type Bus = {
id: string
company: string
depart: string
arrive: string
minutes: number
from: string
to: string
kind: string
vip: boolean
price: number
listPrice?: number
seats: number
logo: { color: "primary" | "success" | "warning" | "destructive" | "secondary"; icon?: boolean }
}
/** پاسخِ سرویسِ اتوبوس برای تهران به اصفهان؛ پایانه و نوعِ اتوبوس از خودِ شرکت میآید. */
const BUSES: Bus[] = [
{ id: "b1", company: "همسفر چابکسواران", depart: "06:30", arrive: "12:45", minutes: 375, from: "پایانهٔ جنوب", to: "پایانهٔ کاوه", kind: "VIP ۲۵ نفره", vip: true, price: 485000, listPrice: 540000, seats: 11, logo: { color: "primary", icon: true } },
{ id: "b2", company: "سیر و سفر", depart: "08:00", arrive: "14:30", minutes: 390, from: "پایانهٔ جنوب", to: "پایانهٔ صفه", kind: "معمولی ۴۴ نفره", vip: false, price: 290000, seats: 26, logo: { color: "success" } },
{ id: "b3", company: "رویال سفر ایرانیان", depart: "13:15", arrive: "19:20", minutes: 365, from: "پایانهٔ آرژانتین", to: "پایانهٔ کاوه", kind: "VIP ۲۵ نفره", vip: true, price: 510000, seats: 3, logo: { color: "warning" } },
{ id: "b4", company: "ایرانپیما", depart: "16:45", arrive: "23:00", minutes: 375, from: "پایانهٔ غرب", to: "پایانهٔ کاوه", kind: "VIP ۳۰ نفره", vip: true, price: 455000, seats: 17, logo: { color: "destructive", icon: true } },
{ id: "b5", company: "سیر و سفر", depart: "22:30", arrive: "04:40", minutes: 370, from: "پایانهٔ جنوب", to: "پایانهٔ صفه", kind: "معمولی ۴۴ نفره", vip: false, price: 275000, seats: 32, logo: { color: "success" } },
]
const COMPANIES = [...new Set(BUSES.map((bus) => bus.company))]
const TIMES = [
{ value: "morning", label: "صبح", from: 0, to: 12 * 60 },
{ value: "noon", label: "ظهر", from: 12 * 60, to: 17 * 60 },
{ value: "night", label: "شب", from: 17 * 60, to: 24 * 60 },
]
/** چهارشنبه ۲۹ مهر ۱۴۰۵؛ ثابت تا عکسِ گالری هر بار یکی باشد. */
const BASE = new Date(2026, 9, 21)
const DAYS: FareDay[] = [275000, 290000, 275000, 310000, 290000, 340000, 275000].map((price, index) => ({
date: new Date(BASE.getFullYear(), BASE.getMonth(), BASE.getDate() + index - 2),
price,
}))
const hm = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3))
const duration = (minutes: number) => `${toFaDigits(Math.floor(minutes / 60))} ساعت${minutes % 60 ? ` و ${toFaDigits(minutes % 60)} دقیقه` : ""}`
const dayLabel = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { weekday: "long", day: "numeric", month: "long" })
type Filters = { times: string[]; companies: string[]; vip: boolean }
const EMPTY: Filters = { times: [], companies: [], vip: false }
/** فیلترها؛ همین در ستونِ کناریِ دسکتاپ و در Sheetِ گوشی. */
function FilterFields({ id, filters, onChange }: { id: string; filters: Filters; onChange: (next: Filters) => void }) {
return (
<div className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-3">
<legend className="mb-3 text-sm font-bold">ساعتِ حرکت</legend>
<ControlSizeProvider size="default" asChild>
<ToggleGroup type="multiple" variant="outline" separated value={filters.times} onValueChange={(value) => onChange({ ...filters, times: value as string[] })} aria-label="ساعتِ حرکت" className="flex-wrap justify-start gap-2">
{TIMES.map((time) => (
<ToggleGroupItem key={time.value} value={time.value} className="rounded-full! px-4">
{time.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</ControlSizeProvider>
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-3 text-sm font-bold">شرکتِ مسافربری</legend>
{COMPANIES.map((company, index) => (
<div key={company} className="flex items-center gap-2">
<Checkbox
id={`${id}-company-${index}`}
checked={filters.companies.includes(company)}
onCheckedChange={(checked) => onChange({ ...filters, companies: checked === true ? [...filters.companies, company] : filters.companies.filter((item) => item !== company) })}
/>
<Label htmlFor={`${id}-company-${index}`} className="font-normal">
{company}
</Label>
</div>
))}
</fieldset>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={`${id}-vip`}>فقط اتوبوسِ VIP</Label>
<Switch id={`${id}-vip`} checked={filters.vip} onCheckedChange={(vip) => onChange({ ...filters, vip })} />
</div>
</div>
)
}
/**
* نتایجِ اتوبوس با فیلتر: ستونِ کناری در دسکتاپ (ساعتِ حرکت، شرکتِ مسافربری، فقط VIP) و همان فیلترها در
* Sheet در گوشی با شمارِ فیلترِ فعال. کارتها پایانهٔ مبدأ و مقصد، نوعِ اتوبوس، تخفیف و صندلیِ باقیمانده را
* دارند؛ اگر فیلترها نتیجهای نگذارند، حالتِ خالی با «پاککردنِ فیلترها» میآید.
*/
export default function TicketResultsBusBlock() {
const [date, setDate] = React.useState(BASE)
const [sort, setSort] = React.useState("early")
const [filters, setFilters] = React.useState<Filters>(EMPTY)
const active = filters.times.length + filters.companies.length + (filters.vip ? 1 : 0)
const buses = BUSES.filter(
(bus) =>
(filters.times.length === 0 || filters.times.some((value) => TIMES.some((time) => time.value === value && hm(bus.depart) >= time.from && hm(bus.depart) < time.to))) &&
(filters.companies.length === 0 || filters.companies.includes(bus.company)) &&
(!filters.vip || bus.vip)
).sort((a, b) => (sort === "early" ? hm(a.depart) - hm(b.depart) : a.price - b.price))
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6">
<header className="flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-card sm:flex-row sm:items-center sm:p-5">
<span aria-hidden className="hidden size-12 shrink-0 items-center justify-center rounded-lg bg-primary-subtle text-primary-subtle-foreground sm:flex [&>svg]:size-6">
<BusIcon />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h1 className="text-lg leading-snug font-black sm:text-xl">بلیتِ اتوبوسِ تهران به اصفهان</h1>
<p className="text-sm text-muted-foreground">{dayLabel.format(date)} · ۲ صندلی</p>
</div>
<ControlSizeProvider size="lg" mobileSize="default" asChild>
<div className="flex">
<Button variant="outline" onClick={() => toast("فرمِ جستوجو با همین مسیر باز میشود.")} className="w-full sm:w-auto">
<EditIcon />
ویرایشِ جستوجو
</Button>
</div>
</ControlSizeProvider>
</header>
<section aria-label="روزِ حرکت" className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">کمترین قیمتِ هر روز (هزار تومان)</p>
<FareDateStrip days={DAYS} value={date} onValueChange={setDate} aria-label="روزِ حرکت" className="-m-1" />
</section>
<div className="grid gap-6 lg:grid-cols-[17rem_minmax(0,1fr)] lg:items-start">
<aside aria-labelledby="bus-filters" className="hidden flex-col gap-5 rounded-xl border bg-card p-5 lg:flex">
<div className="flex items-center justify-between gap-2">
<h2 id="bus-filters" className="font-bold">
فیلترها
</h2>
{active > 0 && (
<ControlSizeProvider size="default" asChild>
<Button variant="ghost" onClick={() => setFilters(EMPTY)} className="-me-2">
پاککردن
</Button>
</ControlSizeProvider>
)}
</div>
<FilterFields id="side" filters={filters} onChange={setFilters} />
</aside>
<section aria-labelledby="bus-results" className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 id="bus-results" className="font-bold" aria-live="polite">
{toFaDigits(buses.length)} اتوبوسِ تهران به اصفهان
</h2>
<ControlSizeProvider size="lg" mobileSize="default" asChild>
<div className="flex gap-2">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" className="lg:hidden">
<FilterIcon />
فیلترها
{active > 0 && (
<Badge size="sm" className="ms-1">
{toFaDigits(active)}
</Badge>
)}
</Button>
</SheetTrigger>
<ControlSizeProvider size="xl" asChild>
<SheetContent side="bottom" className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-xl p-0">
<SheetHeader className="border-b p-5">
<SheetTitle>فیلترها</SheetTitle>
<SheetDescription className="sr-only">ساعتِ حرکت، شرکت و نوعِ اتوبوس</SheetDescription>
</SheetHeader>
<div className="p-5">
<FilterFields id="sheet" filters={filters} onChange={setFilters} />
</div>
<SheetFooter className="flex-row gap-2 border-t p-5">
<Button variant="outline" onClick={() => setFilters(EMPTY)} className="flex-1">
پاککردن
</Button>
</SheetFooter>
</SheetContent>
</ControlSizeProvider>
</Sheet>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger aria-label="مرتبسازی" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="early">زودترین حرکت</SelectItem>
<SelectItem value="cheap">ارزانترین</SelectItem>
</SelectContent>
</Select>
</div>
</ControlSizeProvider>
</div>
{buses.length > 0 ? (
<ul className="flex flex-col gap-4">
{buses.map((bus) => (
<li key={bus.id}>
<TicketCard
departure={{ time: bus.depart, place: "تهران" }}
arrival={{ time: bus.arrive, place: "اصفهان", nextDay: hm(bus.arrive) < hm(bus.depart) }}
duration={duration(bus.minutes)}
icon={<BusIcon />}
logo={bus.logo.icon ? <Avatar color={bus.logo.color} icon={<BusIcon className="size-5" />} alt={bus.company} className="size-10" /> : <Avatar color={bus.logo.color} name={bus.company} className="size-10" />}
operator={bus.company}
details={
<>
<TicketCardDetail icon={<LocationIcon />}>از {bus.from}</TicketCardDetail>
<TicketCardDetail icon={<LocationIcon />}>به {bus.to}</TicketCardDetail>
</>
}
tags={
<Badge variant="soft" size="lg" color={bus.vip ? "violet" : "default"}>
{bus.kind}
</Badge>
}
seatsLeft={bus.seats}
lowSeats={5}
price={formatCurrency(bus.price)}
originalPrice={bus.listPrice ? formatCurrency(bus.listPrice) : undefined}
priceLabel="قیمتِ هر صندلی"
action={<Button onClick={() => toast.success(`${bus.company}، ساعتِ ${toFaDigits(bus.depart)}؛ گامِ بعد انتخابِ صندلی.`)}>انتخابِ صندلی</Button>}
/>
</li>
))}
</ul>
) : (
<EmptyState variant="bordered">
<EmptyStateIcon>
<FilterIcon />
</EmptyStateIcon>
<EmptyStateTitle>با این فیلترها اتوبوسی نیست</EmptyStateTitle>
<EmptyStateDescription>یکی از فیلترها را بردارید یا روزِ دیگری را ببینید.</EmptyStateDescription>
<EmptyStateActions>
<Button variant="outline" onClick={() => setFilters(EMPTY)}>
پاککردنِ فیلترها
</Button>
</EmptyStateActions>
</EmptyState>
)}
</section>
</div>
</main>
</ControlSizeProvider>
)
}