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

جست‌وجوی بلیط

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

مبدأ و مقصد، یک‌طرفه یا رفت و برگشت، تاریخ شمسی و تعداد مسافر.

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

این بلاک از ۱۱ آیتم دیگ استفاده می‌کند: autocompletebuttoncontrol-sizedate-pickerdate-range-pickerlabelnumber-fieldpersianpopovertoasttoggle-group

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/autocomplete.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/date-range-picker.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/popover.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/autocomplete.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/date-range-picker.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/popover.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/autocomplete.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/date-range-picker.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/popover.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/autocomplete.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/date-range-picker.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/popover.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json

۲. کد صفحه

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

travel-search.tsx
"use client"

import * as React from "react"
import type { DateRange } from "react-day-picker"

import { Autocomplete } from "@/components/ui/autocomplete"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { DatePicker } from "@/components/ui/date-picker"
import { DateRangePicker } from "@/components/ui/date-range-picker"
import { Label } from "@/components/ui/label"
import { NumberField } from "@/components/ui/number-field"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits } from "@/lib/persian"

const CITIES = [
  { value: "THR", label: "تهران" },
  { value: "MHD", label: "مشهد" },
  { value: "IFN", label: "اصفهان" },
  { value: "SYZ", label: "شیراز" },
  { value: "TBZ", label: "تبریز" },
  { value: "KIH", label: "کیش" },
]
const POPULAR = [
  { from: "THR", to: "MHD" },
  { from: "THR", to: "KIH" },
  { from: "IFN", to: "MHD" },
  { from: "THR", to: "SYZ" },
]
const cityName = (code: string) => CITIES.find((city) => city.value === code)?.label ?? ""

function SwapIcon({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={className}>
      <path d="M7 7h11l-3-3M17 17H6l3 3" />
    </svg>
  )
}

/** مسیرِ پروازِ تزئینی با هواپیما در بنر. */
function FlightPath() {
  return (
    <svg aria-hidden viewBox="0 0 320 120" className="pointer-events-none absolute end-4 top-4 -z-10 hidden w-80 text-[color-mix(in_oklab,var(--dig-white)_45%,transparent)] sm:block" fill="none">
      <path d="M8 104 C 90 20, 210 10, 300 40" stroke="currentColor" strokeWidth="2" strokeDasharray="2 8" strokeLinecap="round" />
      <circle cx="8" cy="104" r="5" fill="currentColor" />
      <g transform="translate(296 38) rotate(200)" fill="var(--dig-white)">
        <path d="M0 0 L-6 -3 L-18 -3 L-22 -10 L-26 -10 L-23 -3 L-32 -3 L-35 -7 L-38 -7 L-36 0 L-38 7 L-35 7 L-32 3 L-23 3 L-26 10 L-22 10 L-18 3 L-6 3 Z" />
      </g>
    </svg>
  )
}

/**
 * جست‌وجوی پرواز: بنرِ گرادیانی با مسیرِ پرواز و تیترِ پرسشی، و کارتِ جست‌وجو که روی لبهٔ
 * بنر می‌نشیند. نوعِ سفر بالای فیلدها، دکمهٔ جابه‌جاییِ مبدأ و مقصد، مسافران در پاپ‌اوور،
 * و مسیرهای پرطرفدار برای پرکردنِ یک‌لمسی. دکمهٔ جست‌وجو غیرفعال نمی‌شود؛ اگر مقصد خالی
 * باشد خطا کنارِ همان فیلد می‌آید.
 */
export default function TravelSearchBlock() {
  const [trip, setTrip] = React.useState("oneway")
  const [origin, setOrigin] = React.useState("THR")
  const [destination, setDestination] = React.useState("")
  const [departure, setDeparture] = React.useState<Date>()
  const [range, setRange] = React.useState<DateRange>()
  const [adults, setAdults] = React.useState(1)
  const [children, setChildren] = React.useState(0)
  const [missingDestination, setMissingDestination] = React.useState(false)
  const passengers = adults + children

  function swap() {
    setOrigin(destination)
    setDestination(origin)
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl flex-col gap-8 p-4 sm:p-6">
        <div className="flex flex-col">
          {/* بنر: گرادیانِ بنفش به نیلی با پترن و مسیرِ پرواز. */}
          <header className="relative isolate overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--dig-violet-700),var(--dig-indigo-800))] px-5 pt-6 pb-20 text-[var(--dig-white)] sm:px-10 sm:pt-10 sm:pb-24">
            <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_90%_0%,var(--dig-violet-400),transparent_45%),radial-gradient(circle_at_0%_100%,var(--dig-blue-500),transparent_40%)]" />
            <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(color-mix(in_oklab,var(--dig-white)_30%,transparent)_1.2px,transparent_1.3px)] [background-size:18px_18px] [mask-image:linear-gradient(to_left,black,transparent_60%)]" />
            <FlightPath />
            <div className="flex max-w-md flex-col gap-2">
              <h1 className="text-2xl font-black sm:text-4xl">کجا پرواز کنیم؟</h1>
              <p className="text-sm text-[var(--dig-violet-100)] sm:text-base">پروازهای داخلی از ۱۲ ایرلاین، با قیمتِ نهایی.</p>
            </div>
          </header>

          {/* کارتِ جست‌وجو روی لبهٔ بنر. */}
          <form
            noValidate
            className="relative mx-3 -mt-14 flex flex-col gap-5 rounded-xl border border-border bg-card p-4 shadow-card sm:mx-6 sm:p-6"
            onSubmit={(event) => {
              event.preventDefault()
              if (!destination) {
                setMissingDestination(true)
                return
              }
              toast.success(`پروازهای ${cityName(origin)} به ${cityName(destination)}…`)
            }}
          >
            <ToggleGroup type="single" variant="segmented" value={trip} onValueChange={(value) => value && setTrip(value as string)} aria-label="نوع سفر" className="self-start">
              <ToggleGroupItem value="oneway">یک‌طرفه</ToggleGroupItem>
              <ToggleGroupItem value="round">رفت و برگشت</ToggleGroupItem>
            </ToggleGroup>

            <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,0.8fr)_auto] lg:items-start">
              {/* مبدأ و مقصد؛ دکمهٔ جابه‌جایی در دسکتاپ ستونی میانِ دو فیلد است و در موبایل کنارِ برچسبِ مقصد. */}
              <div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-start sm:gap-2">
                <div className="flex flex-col gap-2">
                  <Label htmlFor="origin">مبدأ</Label>
                  <Autocomplete id="origin" items={CITIES.filter((city) => city.value !== destination)} value={origin} onValueChange={setOrigin} placeholder="شهرِ مبدأ" className="w-full" />
                </div>
                <Button type="button" variant="ghost" size="icon" aria-label="جابه‌جاییِ مبدأ و مقصد" disabled={!destination} onClick={swap} className="hidden rounded-full sm:mt-7 sm:flex">
                  <SwapIcon />
                </Button>
                <div className="flex flex-col gap-2">
                  <div className="flex h-5 items-center justify-between gap-2">
                    <Label htmlFor="destination">مقصد</Label>
                    <ControlSizeProvider size="lg" asChild>
                      <Button type="button" variant="ghost" aria-label="جابه‌جاییِ مبدأ و مقصد" disabled={!destination} onClick={swap} className="-me-2 gap-1.5 px-2 sm:hidden">
                        <SwapIcon className="rotate-90" />
                        جابه‌جایی
                      </Button>
                    </ControlSizeProvider>
                  </div>
                  <Autocomplete
                    id="destination"
                    items={CITIES.filter((city) => city.value !== origin)}
                    value={destination}
                    onValueChange={(value) => {
                      setDestination(value)
                      setMissingDestination(false)
                    }}
                    placeholder="شهرِ مقصد"
                    emptyText="شهری پیدا نشد"
                    aria-invalid={missingDestination || undefined}
                    aria-describedby={missingDestination ? "destination-error" : undefined}
                    className="w-full"
                  />
                  {missingDestination && (
                    <p id="destination-error" className="text-sm text-destructive">
                      مقصد را انتخاب کن.
                    </p>
                  )}
                </div>
              </div>

              {trip === "round" ? (
                <div className="flex flex-col gap-2">
                  <Label>تاریخ رفت و برگشت</Label>
                  <DateRangePicker calendar="jalali" value={range} onChange={setRange} numberOfMonths={2} className="w-full" />
                </div>
              ) : (
                <DatePicker id="departure" label="تاریخ رفت" calendar="jalali" value={departure} onChange={setDeparture} minDate={new Date()} className="w-full" />
              )}

              <div className="flex flex-col gap-2">
                <Label id="passengers-label">مسافران</Label>
                <Popover>
                  <PopoverTrigger asChild>
                    <Button variant="outline" aria-labelledby="passengers-label" className="justify-between">
                      {toFaDigits(passengers)} مسافر
                    </Button>
                  </PopoverTrigger>
                  <ControlSizeProvider size="xl" asChild>
                    <PopoverContent align="end" className="flex w-64 flex-col gap-3">
                      <div className="flex items-center justify-between gap-2">
                        <Label id="adults">بزرگسال</Label>
                        <NumberField aria-labelledby="adults" value={adults} onValueChange={setAdults} min={1} max={9 - children} className="w-28" />
                      </div>
                      <div className="flex items-center justify-between gap-2">
                        <Label id="children">کودک (۲ تا ۱۲ سال)</Label>
                        <NumberField aria-labelledby="children" value={children} onValueChange={setChildren} min={0} max={9 - adults} className="w-28" />
                      </div>
                    </PopoverContent>
                  </ControlSizeProvider>
                </Popover>
              </div>

              <Button type="submit" className="lg:mt-7 lg:min-w-32">
                جست‌وجو
              </Button>
            </div>
          </form>
        </div>

        {/* مسیرهای پرطرفدار: یک لمس، مبدأ و مقصد پر می‌شود. */}
        <section aria-labelledby="popular-title" className="flex flex-col gap-3">
          <h2 id="popular-title" className="font-bold">
            مسیرهای پرطرفدار
          </h2>
          <div className="flex gap-2 overflow-x-auto pb-1 sm:flex-wrap">
            {POPULAR.map((route) => (
              <Button
                key={`${route.from}-${route.to}`}
                type="button"
                variant="outline"
                className="shrink-0"
                onClick={() => {
                  setOrigin(route.from)
                  setDestination(route.to)
                  setMissingDestination(false)
                }}
              >
                {cityName(route.from)}
                <span aria-label="به" className="text-muted-foreground">
                  ←
                </span>
                {cityName(route.to)}
              </Button>
            ))}
          </div>
        </section>
      </main>
    </ControlSizeProvider>
  )
}