بلاک‌ها · رزرو و خدمات

نتایجِ بلیتِ اتوبوس

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

نتایجِ اتوبوس با فیلترِ کنارِ فهرست: بازهٔ ساعتِ حرکت، شرکتِ مسافربری و نوعِ اتوبوس (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.json
pnpm 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.json
yarn 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.json
bunx --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>
  )
}