نتایجِ بلیتِ قطار
پیشنمایش تمامصفحهنتایجِ جستوجوی قطار: خلاصهٔ مسیر و تاریخ با ویرایش، نوارِ هفت روز با کمترین قیمتِ هر روز، شمار و مرتبسازی، کارتِ قطار با ساعت، شرکت، شمارهٔ قطار، نوعِ واگن، صندلیِ باقیمانده و قیمتِ هر بزرگسال، بخشِ تکمیلِ ظرفیت با «خبرم کن» و پرسشهای پرتکرار.
۱. نصب کامپوننتها
این بلاک از ۱۲ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}