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

نتایجِ بلیتِ هواپیما

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

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

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

این بلاک از ۱۴ آیتم دیگ استفاده می‌کند: 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.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/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
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/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
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/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>
  )
}