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

نتایجِ بلیتِ قطار

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

نتایجِ جست‌وجوی قطار: خلاصهٔ مسیر و تاریخ با ویرایش، نوارِ هفت روز با کم‌ترین قیمتِ هر روز، شمار و مرتب‌سازی، کارتِ قطار با ساعت، شرکت، شمارهٔ قطار، نوعِ واگن، صندلیِ باقی‌مانده و قیمتِ هر بزرگسال، بخشِ تکمیلِ ظرفیت با «خبرم کن» و پرسش‌های پرتکرار.

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

این بلاک از ۱۲ آیتم دیگ استفاده می‌کند: accordionavatarbadgebuttoncontrol-sizefare-date-stripiconspersianselectskeletonticket-cardtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/accordion.json 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/control-size.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/skeleton.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/accordion.json 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/control-size.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/skeleton.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/accordion.json 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/control-size.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/skeleton.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/accordion.json 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/control-size.json https://docs.digdesign.ir/r/fare-date-strip.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/skeleton.json https://docs.digdesign.ir/r/ticket-card.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

ticket-results-train.tsx
"use client"

import * as React from "react"

import { CalendarIcon, EditIcon, TrainIcon } from "@/components/icons"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { FareDateStrip, type FareDay } from "@/components/ui/fare-date-strip"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { TicketCard, TicketCardDetail } from "@/components/ui/ticket-card"
import { toast } from "@/components/ui/toast"
import { formatCurrency, toFaDigits } from "@/lib/persian"

type Train = {
  id: string
  company: string
  name: string
  number: number
  depart: string
  arrive: string
  minutes: number
  wagon: string
  price: number
  seats: number
  /** نشانِ شرکت: حروفِ اول با رنگ یا آیکون. */
  logo: { color: "primary" | "success" | "warning" | "destructive" | "secondary"; icon?: boolean }
}

/** همان شکلِ داده‌ای که سرویسِ فروشِ بلیتِ قطار برمی‌گرداند (بی‌فیلدهای داخلی). */
const TRAINS: Train[] = [
  { id: "t318", company: "رجا", name: "۴ ستاره اتوبوسی صبا", number: 318, depart: "09:20", arrive: "21:45", minutes: 745, wagon: "اتوبوسی ۴ ردیفه", price: 760000, seats: 194, logo: { color: "primary", icon: true } },
  { id: "t332", company: "جوپار", name: "۳ ستاره ۶ تخته کویر", number: 332, depart: "13:10", arrive: "23:30", minutes: 620, wagon: "کوپهٔ ۶ نفره", price: 845000, seats: 6, logo: { color: "warning" } },
  { id: "t346", company: "ریل پرداز نوآفرین", name: "۵ ستاره اکونومی پلاس فدک", number: 346, depart: "16:40", arrive: "04:05", minutes: 685, wagon: "کوپهٔ ۴ نفره", price: 1290000, seats: 18, logo: { color: "success" } },
  { id: "t370", company: "رجا", name: "۴ ستاره سالنی پردیس", number: 370, depart: "19:05", arrive: "06:50", minutes: 705, wagon: "سالنی ۴ ردیفه", price: 905000, seats: 3, logo: { color: "primary", icon: true } },
]
const SOLD_OUT: Train[] = [
  { id: "t584", company: "نورالرضا", name: "۵ ستاره اکونومی پلاس (پذیرایی شام)", number: 584, depart: "22:55", arrive: "09:20", minutes: 625, wagon: "کوپهٔ ۴ نفره", price: 1450000, seats: 0, logo: { color: "secondary" } },
  { id: "t312", company: "رجا", name: "۴ ستاره سالنی پردیس", number: 312, depart: "07:25", arrive: "16:10", minutes: 525, wagon: "سالنی ۴ ردیفه", price: 760000, seats: 0, logo: { color: "primary", icon: true } },
]

/** دوشنبه ۲۷ مهر ۱۴۰۵؛ تاریخِ ثابت تا عکسِ گالری هر بار یکی باشد. */
const BASE = new Date(2026, 9, 19)
const addDays = (days: number) => new Date(BASE.getFullYear(), BASE.getMonth(), BASE.getDate() + days)
/** کم‌ترین قیمتِ هر روز از سرویسِ تقویمِ قیمت؛ جمعه همه فروخته شده. */
const DAYS: FareDay[] = [-2, -1, 0, 1, 2, 3, 4].map((offset, index) => ({
  date: addDays(offset),
  price: [760000, 760000, 760000, 820000, 760000, 905000, null][index],
  soldOut: offset === 4,
}))

const SORTS = [
  { value: "cheap", label: "ارزان‌ترین" },
  { value: "early", label: "زودترین حرکت" },
  { value: "late", label: "دیرترین حرکت" },
  { value: "fast", label: "کوتاه‌ترین سفر" },
]
const FAQ = [
  { q: "قیمتِ بلیتِ قطارِ تهران به مشهد چقدر است؟", a: "بسته به نوعِ قطار و واگن از حدودِ ۷۶۰ هزار تا یک‌ونیم میلیون تومان است. قیمتِ هر روز در نوارِ بالای نتایج دیده می‌شود." },
  { q: "کودک و نوزاد چه مقدار بلیت می‌پردازند؟", a: "کودکِ ۲ تا ۱۲ سال نیم‌بها و نوزادِ زیرِ ۲ سال بدونِ صندلی، با ده درصدِ قیمت سوار می‌شود." },
  { q: "استرداد بلیتِ قطار چطور است؟", a: "تا ۳ ساعت پیش از حرکت از بخشِ «سفرهای من» لغو کنید؛ جریمه طبقِ قانونِ شرکتِ ریلی کم و بقیه به کارتتان برمی‌گردد." },
  { q: "کوپهٔ دربست چه فرقی دارد؟", a: "همهٔ تخت‌های یک کوپه برای شما رزرو می‌شود، حتی اگر کمتر از ظرفیتِ کوپه مسافر باشید." },
]

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" })
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString()

function OperatorLogo({ train }: { train: Train }) {
  return train.logo.icon ? (
    <Avatar color={train.logo.color} icon={<TrainIcon className="size-5" />} alt={train.company} className="size-10" />
  ) : (
    <Avatar color={train.logo.color} name={train.company} className="size-10" />
  )
}

function TrainCard({ train, date, soldOut }: { train: Train; date: Date; soldOut?: boolean }) {
  const [notify, setNotify] = React.useState(false)
  return (
    <TicketCard
      departure={{ time: train.depart, place: "تهران" }}
      arrival={{ time: train.arrive, place: "مشهد", nextDay: hm(train.arrive) < hm(train.depart) }}
      duration={duration(train.minutes)}
      icon={<TrainIcon />}
      logo={<OperatorLogo train={train} />}
      operator={train.name}
      details={
        <>
          <TicketCardDetail icon={<CalendarIcon />}>{dayLabel.format(date)}</TicketCardDetail>
          <TicketCardDetail icon={<TrainIcon />}>
            {train.company} / شمارهٔ قطار {toFaDigits(train.number)}
          </TicketCardDetail>
        </>
      }
      tags={<Badge variant="soft" size="lg">{train.wagon}</Badge>}
      seatsLeft={train.seats}
      price={formatCurrency(train.price)}
      soldOut={soldOut}
      soldOutHint={soldOut ? "اگر کسی بلیتش را پس بدهد خبرتان می‌کنیم." : undefined}
      action={
        soldOut ? (
          <Button
            variant="outline"
            aria-pressed={notify}
            onClick={() => {
              setNotify(!notify)
              if (!notify) toast.success(`آزاد شدنِ صندلیِ قطارِ ${toFaDigits(train.number)} را خبر می‌دهیم.`)
            }}
          >
            {notify ? "خبرتان می‌کنیم" : "خبرم کن"}
          </Button>
        ) : (
          <Button onClick={() => toast.success(`قطارِ ${toFaDigits(train.number)} انتخاب شد؛ گامِ بعد مشخصاتِ مسافران.`)}>انتخاب و خرید</Button>
        )
      }
    />
  )
}

/**
 * نتایجِ جست‌وجوی قطار: خلاصهٔ جست‌وجو با ویرایش، نوارِ هفت روز با کم‌ترین قیمتِ هر روز، شمارِ نتایج
 * و مرتب‌سازی، کارت‌های قطار با ساعت، شرکت، شمارهٔ قطار، نوعِ واگن، صندلیِ باقی‌مانده (کم که شد قرمز)
 * و قیمتِ هر بزرگسال، بخشِ «تکمیلِ ظرفیت» با سرِ تیره و «خبرم کن»، و پرسش‌های پرتکرار.
 */
export default function TicketResultsTrainBlock() {
  const [date, setDate] = React.useState(BASE)
  const [sort, setSort] = React.useState("cheap")
  const [loading, setLoading] = React.useState(false)
  const timer = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])

  const day = DAYS.find((item) => sameDay(item.date, date))
  const soldOutDay = Boolean(day?.soldOut)
  const trains = soldOutDay ? [] : [...TRAINS].sort((a, b) => (sort === "cheap" ? a.price - b.price : sort === "early" ? hm(a.depart) - hm(b.depart) : sort === "late" ? hm(b.depart) - hm(a.depart) : a.minutes - b.minutes))
  const soldOut = soldOutDay ? [...SOLD_OUT, ...TRAINS.map((train) => ({ ...train, seats: 0 }))] : SOLD_OUT

  const pickDay = (next: Date) => {
    setDate(next)
    setLoading(true)
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setLoading(false), 600)
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl 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">
            <TrainIcon />
          </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-labelledby="days-title" className="flex flex-col gap-2">
          <h2 id="days-title" className="text-sm text-muted-foreground">
            کم‌ترین قیمتِ هر روز (هزار تومان)
          </h2>
          <FareDateStrip days={DAYS} value={date} onValueChange={pickDay} aria-label="روزِ حرکت" className="-m-1" />
        </section>

        <section aria-labelledby="results-title" aria-busy={loading} className="flex flex-col gap-4">
          <div className="flex flex-wrap items-center justify-between gap-3">
            <h2 id="results-title" className="font-bold" aria-live="polite">
              {loading ? "در حالِ جست‌وجو…" : `${toFaDigits(trains.length)} قطارِ تهران به مشهد`}
            </h2>
            <ControlSizeProvider size="lg" mobileSize="default" asChild>
              <div className="flex">
                <Select value={sort} onValueChange={setSort}>
                  <SelectTrigger aria-label="مرتب‌سازی" className="w-44">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {SORTS.map((item) => (
                      <SelectItem key={item.value} value={item.value}>
                        {item.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
            </ControlSizeProvider>
          </div>

          {loading ? (
            <div className="flex flex-col gap-4">
              {[0, 1].map((key) => (
                <Skeleton key={key} className="h-56 rounded-xl" />
              ))}
            </div>
          ) : trains.length > 0 ? (
            <ul className="flex flex-col gap-4">
              {trains.map((train) => (
                <li key={train.id}>
                  <TrainCard train={train} date={date} />
                </li>
              ))}
            </ul>
          ) : (
            <p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">همهٔ قطارهای این روز پر شده‌اند؛ روزِ دیگری را از نوارِ بالا انتخاب کنید.</p>
          )}
        </section>

        {!loading && (
          <section aria-labelledby="soldout-title" className="flex flex-col gap-4">
            <h2 id="soldout-title" className="font-bold">
              قطارهای تکمیلِ ظرفیت
            </h2>
            <ul className="flex flex-col gap-4">
              {soldOut.map((train) => (
                <li key={train.id}>
                  <TrainCard train={train} date={date} soldOut />
                </li>
              ))}
            </ul>
          </section>
        )}

        <section aria-labelledby="faq-title" className="flex flex-col gap-3 pt-4">
          <h2 id="faq-title" className="text-lg leading-snug font-black">
            پرسش‌های پرتکرارِ قطارِ تهران به مشهد
          </h2>
          <Accordion type="single" collapsible variant="splitted">
            {FAQ.map((item) => (
              <AccordionItem key={item.q} value={item.q}>
                <AccordionTrigger className="text-base font-semibold">{item.q}</AccordionTrigger>
                <AccordionContent>{item.a}</AccordionContent>
              </AccordionItem>
            ))}
          </Accordion>
        </section>
      </main>
    </ControlSizeProvider>
  )
}