نتایجِ بلیتِ هواپیما
پیشنمایش تمامصفحهنتایجِ پرواز: خلاصهٔ رفت و برگشت، نوارِ روزها با کمترین قیمت، برگههای «رفت» و «برگشت»، فیلترِ ایرلاین و پروازِ بیتوقف، کارتِ پرواز با ایرلاین، شمارهٔ پرواز، کلاس، بارِ مجاز، چارتری یا سیستمی و صندلیِ باقیمانده.
۱. نصب کامپوننتها
این بلاک از ۱۴ آیتم دیگ استفاده میکند: accordionavatarbadgebuttoncontrol-sizeempty-statefare-date-stripiconspersianselecttabsticket-cardtoasttoggle-group
پکیجمنیجر پیشفرض 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/empty-state.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/tabs.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/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/empty-state.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/tabs.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/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/empty-state.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/tabs.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/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/empty-state.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/tabs.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-flight.tsx
"use client"
import * as React from "react"
import { EditIcon, PlaneIcon, SuitcaseIcon, TicketIcon } 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 { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIcon, EmptyStateTitle } from "@/components/ui/empty-state"
import { FareDateStrip, type FareDay } from "@/components/ui/fare-date-strip"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
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 Leg = "out" | "back"
type Flight = {
id: string
airline: string
code: string
number: number
aircraft: string
depart: string
arrive: string
minutes: number
cabin: "اکونومی" | "بیزینس"
charter: boolean
baggage: number
price: number
/** قیمتِ پیش از تخفیفِ ایرلاین. */
listPrice?: number
seats: number
logo: { color: "primary" | "success" | "warning" | "destructive" | "secondary"; icon?: boolean }
}
const AIRLINES = ["ایرانایر", "ماهان", "آسمان", "کاسپین", "زاگرس"]
/** شکلِ پاسخِ سرویسِ جستوجوی پرواز برای هر مسیر. */
const FLIGHTS: Record<Leg, Flight[]> = {
out: [
{ id: "ep3412", airline: "آسمان", code: "EP", number: 3412, aircraft: "فوکر ۱۰۰", depart: "06:10", arrive: "07:40", minutes: 90, cabin: "اکونومی", charter: false, baggage: 20, price: 2480000, seats: 4, logo: { color: "success" } },
{ id: "w51062", airline: "ماهان", code: "W5", number: 1062, aircraft: "ایرباس ۳۰۰", depart: "09:45", arrive: "11:10", minutes: 85, cabin: "اکونومی", charter: true, baggage: 30, price: 2650000, listPrice: 2950000, seats: 21, logo: { color: "destructive", icon: true } },
{ id: "ir445", airline: "ایرانایر", code: "IR", number: 445, aircraft: "ایرباس ۳۲۰", depart: "14:30", arrive: "16:00", minutes: 90, cabin: "اکونومی", charter: false, baggage: 20, price: 2790000, seats: 38, logo: { color: "primary", icon: true } },
{ id: "zv4022", airline: "زاگرس", code: "ZV", number: 4022, aircraft: "امدی ۸۳", depart: "21:20", arrive: "22:55", minutes: 95, cabin: "بیزینس", charter: false, baggage: 40, price: 4900000, seats: 2, logo: { color: "warning" } },
],
back: [
{ id: "iv6211", airline: "کاسپین", code: "IV", number: 6211, aircraft: "امدی ۸۳", depart: "08:00", arrive: "09:35", minutes: 95, cabin: "اکونومی", charter: true, baggage: 20, price: 2350000, seats: 9, logo: { color: "secondary" } },
{ id: "ir446", airline: "ایرانایر", code: "IR", number: 446, aircraft: "ایرباس ۳۲۰", depart: "17:15", arrive: "18:45", minutes: 90, cabin: "اکونومی", charter: false, baggage: 20, price: 2790000, seats: 27, logo: { color: "primary", icon: true } },
{ id: "w51063", airline: "ماهان", code: "W5", number: 1063, aircraft: "ایرباس ۳۰۰", depart: "23:40", arrive: "01:05", minutes: 85, cabin: "اکونومی", charter: false, baggage: 30, price: 2600000, seats: 14, logo: { color: "destructive", icon: true } },
],
}
/** یکشنبه ۲۶ مهر و برگشت سهشنبه ۵ آبان ۱۴۰۵؛ ثابت تا عکسِ گالری هر بار یکی باشد. */
const DATES: Record<Leg, Date> = { out: new Date(2026, 9, 18), back: new Date(2026, 9, 27) }
const around = (base: Date, prices: (number | null)[]): FareDay[] =>
prices.map((price, index) => ({ date: new Date(base.getFullYear(), base.getMonth(), base.getDate() + index - 3), price, soldOut: price === null }))
const DAYS: Record<Leg, FareDay[]> = {
out: around(DATES.out, [2650000, 2480000, 2990000, 2480000, 2550000, 3100000, 3400000]),
back: around(DATES.back, [2350000, 2490000, null, 2350000, 2700000, 2600000, 2350000]),
}
const ROUTE: Record<Leg, { from: string; to: string }> = { out: { from: "تهران (مهرآباد)", to: "مشهد" }, back: { from: "مشهد", to: "تهران (مهرآباد)" } }
const SORTS = [
{ value: "cheap", label: "ارزانترین" },
{ value: "early", label: "زودترین پرواز" },
{ value: "seats", label: "بیشترین صندلی" },
]
const FAQ = [
{ 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 short = 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 AirlineLogo({ flight }: { flight: Flight }) {
return flight.logo.icon ? (
<Avatar color={flight.logo.color} icon={<PlaneIcon className="size-5" />} alt={flight.airline} className="size-10" />
) : (
<Avatar color={flight.logo.color} name={flight.airline} className="size-10" />
)
}
/**
* نتایجِ پروازِ رفت و برگشت: خلاصهٔ سفر با ویرایش، برگههای «رفت» و «برگشت» که هر کدام نوارِ روز و
* فهرستِ خودش را دارد، فیلترِ ایرلاین با چیپ، مرتبسازی، کارتِ پرواز با ایرلاین، شمارهٔ پرواز، هواپیما،
* کلاس، چارتری یا سیستمی، بارِ مجاز، قیمتِ تخفیفخورده و صندلیِ باقیمانده؛ انتخابِ رفت شما را به برگشت میبرد.
*/
export default function TicketResultsFlightBlock() {
const [leg, setLeg] = React.useState<Leg>("out")
const [dates, setDates] = React.useState(DATES)
const [sort, setSort] = React.useState("cheap")
const [airlines, setAirlines] = React.useState<string[]>([])
const [picked, setPicked] = React.useState<Partial<Record<Leg, Flight>>>({})
const day = DAYS[leg].find((item) => sameDay(item.date, dates[leg]))
const flights = (day?.soldOut ? [] : FLIGHTS[leg])
.filter((flight) => airlines.length === 0 || airlines.includes(flight.airline))
.sort((a, b) => (sort === "cheap" ? a.price - b.price : sort === "early" ? hm(a.depart) - hm(b.depart) : b.seats - a.seats))
const route = ROUTE[leg]
const choose = (flight: Flight) => {
setPicked((current) => ({ ...current, [leg]: flight }))
if (leg === "out") {
setLeg("back")
setAirlines([])
toast.success(`پروازِ رفت (${flight.airline} ${toFaDigits(flight.number)}) انتخاب شد؛ حالا پروازِ برگشت.`)
} else {
toast.success("رفت و برگشت انتخاب شد؛ گامِ بعد مشخصاتِ مسافران.")
}
}
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">
<PlaneIcon />
</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">
رفت {short.format(dates.out)} · برگشت {short.format(dates.back)} · ۲ بزرگسال، ۱ کودک · اکونومی
</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>
<Tabs variant="line" value={leg} onValueChange={(value) => setLeg(value as Leg)} className="gap-5">
<TabsList aria-label="مسیرِ سفر" className="w-full justify-start">
{(["out", "back"] as Leg[]).map((item) => (
<TabsTrigger key={item} value={item} className="flex-1 flex-col gap-0.5 py-2 sm:flex-none sm:px-6">
<span className="font-bold">{item === "out" ? "پروازِ رفت" : "پروازِ برگشت"}</span>
<span className="text-xs font-normal text-muted-foreground">
{picked[item] ? `${picked[item]!.airline}، ساعتِ ${toFaDigits(picked[item]!.depart)}` : short.format(dates[item])}
</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value={leg} className="flex flex-col gap-5">
<section aria-label="روزِ پرواز" className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">کمترین قیمتِ هر روز (هزار تومان)</p>
<FareDateStrip key={leg} days={DAYS[leg]} value={dates[leg]} onValueChange={(next) => setDates((current) => ({ ...current, [leg]: next }))} aria-label={leg === "out" ? "روزِ رفت" : "روزِ برگشت"} className="-m-1" />
</section>
<section aria-labelledby="flight-results" className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 id="flight-results" className="font-bold" aria-live="polite">
{toFaDigits(flights.length)} پروازِ {route.from} به {route.to}
</h2>
<ControlSizeProvider size="lg" mobileSize="default" asChild>
<div className="flex">
<Select value={sort} onValueChange={setSort}>
<SelectTrigger aria-label="مرتبسازی" className="w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORTS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ControlSizeProvider>
</div>
{/* فیلترِ ایرلاین: چیپهای ۳۶ پیکسلی، چندانتخابی؛ هیچکدام یعنی همه. */}
<ControlSizeProvider size="default" asChild>
<ToggleGroup type="multiple" variant="outline" separated value={airlines} onValueChange={(value) => setAirlines(value as string[])} aria-label="ایرلاین" className="flex-wrap justify-start gap-2">
{AIRLINES.map((airline) => (
<ToggleGroupItem key={airline} value={airline} className="rounded-full! px-4">
{airline}
</ToggleGroupItem>
))}
</ToggleGroup>
</ControlSizeProvider>
{flights.length > 0 ? (
<ul className="flex flex-col gap-4">
{flights.map((flight) => {
const chosen = picked[leg]?.id === flight.id
return (
<li key={flight.id}>
<TicketCard
departure={{ time: flight.depart, place: route.from }}
arrival={{ time: flight.arrive, place: route.to, nextDay: hm(flight.arrive) < hm(flight.depart) }}
duration={duration(flight.minutes)}
icon={<PlaneIcon />}
logo={<AirlineLogo flight={flight} />}
operator={flight.airline}
className={chosen ? "border-primary" : undefined}
details={
<>
<TicketCardDetail icon={<TicketIcon />}>
پروازِ {toFaDigits(flight.number)} <span dir="ltr">{flight.code}</span> · {flight.aircraft}
</TicketCardDetail>
<TicketCardDetail icon={<SuitcaseIcon />}>{toFaDigits(flight.baggage)} کیلو بارِ مجاز</TicketCardDetail>
</>
}
tags={
<>
<Badge variant="soft" size="lg">{flight.cabin}</Badge>
<Badge variant="soft" size="lg" color={flight.charter ? "warning" : "info"}>
{flight.charter ? "چارتری" : "سیستمی"}
</Badge>
</>
}
seatsLeft={flight.seats}
lowSeats={5}
price={formatCurrency(flight.price)}
originalPrice={flight.listPrice ? formatCurrency(flight.listPrice) : undefined}
action={
<Button variant={chosen ? "outline" : "default"} aria-pressed={chosen} onClick={() => choose(flight)}>
{chosen ? "انتخاب شد" : leg === "out" ? "انتخابِ رفت" : "انتخابِ برگشت"}
</Button>
}
/>
</li>
)
})}
</ul>
) : (
<EmptyState variant="bordered">
<EmptyStateIcon>
<PlaneIcon />
</EmptyStateIcon>
<EmptyStateTitle>{day?.soldOut ? "همهٔ پروازهای این روز پر شده" : "پروازی با این ایرلاینها نیست"}</EmptyStateTitle>
<EmptyStateDescription>{day?.soldOut ? "روزِ دیگری را از نوارِ بالا انتخاب کنید." : "ایرلاینِ دیگری را هم انتخاب کنید یا فیلتر را بردارید."}</EmptyStateDescription>
{!day?.soldOut && (
<EmptyStateActions>
<Button variant="outline" onClick={() => setAirlines([])}>
همهٔ ایرلاینها
</Button>
</EmptyStateActions>
)}
</EmptyState>
)}
</section>
</TabsContent>
</Tabs>
<section aria-labelledby="flight-faq" className="flex flex-col gap-3 pt-4">
<h2 id="flight-faq" className="text-lg leading-snug font-black">
پرسشهای پرتکرارِ پروازِ تهران به مشهد
</h2>
<Accordion type="single" collapsible variant="bordered">
{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>
)
}