Перейти к содержимому
v3.0.0
English
Русский
Документация
Все шаблоны

Бронирование

Даты проживания с занятыми ночами, счётчик гостей, расчёт цены и шторка подтверждения.

Код

docs/examples/templates/booking.tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Badge,
  Button,
  CalendarContent,
  CalendarDay,
  CalendarDays,
  CalendarHeader,
  CalendarMonth,
  CalendarMonthLabel,
  CalendarMonths,
  CalendarNextButton,
  CalendarPrevButton,
  CalendarViewButton,
  CalendarViewButtonGroup,
  CalendarWeekday,
  CalendarWeekdays,
  CalendarWeeks,
  CalendarYear,
  CalendarYearLabel,
  CalendarYears,
  Card,
  DateRangeField,
  DateRangeFieldCalendar,
  DateRangeFieldInput,
  DateRangeFieldTriggerIconButton,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Separator,
  Sheet,
  SheetClose,
  SheetContent,
  SheetHandle,
  Text,
  TextArea,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { Car, Flame, Star, Waves, Wifi, XIcon } from '@vega-ui/icons';

const NIGHTLY_RATE = 140;
const CLEANING_FEE = 45;
const SERVICE_RATE = 0.12;
const BOOKED = ['2026-11-20', '2026-11-21', '2026-11-22', '2026-11-23'];

const highlights = [
  { icon: <Waves />, title: 'Private lake access', text: 'A jetty and two kayaks, a two-minute walk from the door.' },
  { icon: <Flame />, title: 'Wood sauna and fireplace', text: 'Firewood is stacked and refilled for every stay.' },
  { icon: <Wifi />, title: 'Fast wifi', text: '300 Mbps fibre and a desk facing the water.' },
  { icon: <Car />, title: 'Free parking', text: 'Space for two cars, with an EV charger.' },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
const day = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const row = { display: 'flex', justifyContent: 'space-between', gap: 'var(--spacing-8)' } as const;
const amount = { fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' } as const;
const closeButton = { flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties;

const countNights = ([checkIn, checkOut]: [string, string]) => {
  if (checkIn === '' || checkOut === '') return 0;

  return Math.round((Date.parse(checkOut) - Date.parse(checkIn)) / 86_400_000);
};

const getStayError = ([checkIn, checkOut]: [string, string]) => {
  if (checkIn === '' || checkOut === '') return 'Choose check-in and check-out dates.';
  if (BOOKED.some((date) => date >= checkIn && date < checkOut)) return 'Nov 20–23 are already booked. Choose dates around them.';
  if (countNights([checkIn, checkOut]) < 2) return 'Stays are two nights or longer.';

  return '';
};

const formatStay = ([checkIn, checkOut]: [string, string]) => `${day.format(Date.parse(checkIn))} – ${day.format(Date.parse(checkOut))}`;

export default function Booking() {
  const id = useId();
  const [stay, setStay] = useState<[string, string]>(['2026-11-13', '2026-11-17']);
  const [guests, setGuests] = useState(2);
  const [stayError, setStayError] = useState('');
  const [open, setOpen] = useState(false);
  const [guestErrors, setGuestErrors] = useState<{ name?: string, email?: string }>({});
  const [requested, setRequested] = useState('');

  const nights = countNights(stay);
  const lodging = nights * NIGHTLY_RATE;
  const service = Math.round(lodging * SERVICE_RATE);
  const total = nights > 0 ? lodging + CLEANING_FEE + service : 0;

  const changeStay = (next: [string, string]) => {
    setStay(next);
    setStayError(next[0] !== '' && next[1] !== '' ? getStayError(next) : '');
    setRequested('');
  };

  const reserve = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const error = getStayError(stay);

    setStayError(error);

    if (error !== '') {
      const input = event.currentTarget.elements.namedItem('stay');
      if (input instanceof HTMLElement) input.focus();
      return;
    }

    setGuestErrors({});
    setOpen(true);
  };

  const sendRequest = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    const name = String(data.get('name')).trim();
    const email = String(data.get('email')).trim();
    const errors: { name?: string, email?: string } = {};

    if (name === '') errors.name = 'Enter the name on your ID.';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Enter an email so the host can reply.';

    setGuestErrors(errors);

    const invalid = Object.keys(errors)[0];

    if (invalid) {
      const element = form.elements.namedItem(invalid);
      if (element instanceof HTMLElement) element.focus();
      return;
    }

    setRequested(name.split(' ')[0]);
    setOpen(false);
  };

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <article aria-labelledby={`${id}-title`} style={{ display: 'grid', gap: 'var(--spacing-12)', flex: '999 1 400px', minWidth: 0 }}>
        <header style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
          <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4)' }}>
            <Badge size='sm' variant='info'>Guest favourite</Badge>
            <Text size={2} style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-2)', ...amount }}>
              <Icon size='xs' aria-hidden='true'><Star /></Icon>
              4.92 · 128 reviews
            </Text>
          </div>
          <Heading id={`${id}-title`} as='h1' size={7} fontWeight={700}>Lakeside cabin with a wood sauna</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>Hallstatt, Austria · 2 bedrooms · 3 beds · 1 bath</Text>
        </header>

        <Text size={3} style={{ maxWidth: '60ch' }}>
          A quiet timber cabin on the western shore, hosted by Marta since 2019. Mornings start with mist over the water,
          evenings end in the sauna. The village and the ferry are a 15-minute walk along the lake.
        </Text>

        <Separator />

        <section aria-labelledby={`${id}-offers`} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
          <Heading id={`${id}-offers`} as='h2' size={4} fontWeight={600}>What this place offers</Heading>
          <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 220px), 1fr))', gap: 'var(--spacing-10) var(--spacing-12)', margin: 0, padding: 0, listStyle: 'none' }}>
            {highlights.map((item) => (
              <li key={item.title} style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-6)' }}>
                <Icon aria-hidden='true' style={{ flexShrink: 0, color: 'var(--label-secondary)' }}>{item.icon}</Icon>
                <span style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
                  <Text size={3} fontWeight={500}>{item.title}</Text>
                  <Text size={2} style={{ color: 'var(--label-secondary)' }}>{item.text}</Text>
                </span>
              </li>
            ))}
          </ul>
        </section>
      </article>

      <Card asChild style={{ alignItems: 'stretch', gap: 'var(--spacing-10)', flex: '1 1 340px', minWidth: 0 }}>
        <form noValidate aria-labelledby={`${id}-price`} onSubmit={reserve}>
          <Text id={`${id}-price`} size={2} style={{ color: 'var(--label-secondary)' }}>
            <Text size={5} fontWeight={700} style={{ color: 'var(--label-primary)', ...amount }}>{currency.format(NIGHTLY_RATE)}</Text>
            {' per night'}
          </Text>

          {requested !== '' && (
            <Alert variant='success'>
              <AlertIcon />
              <AlertMain>
                <AlertTitle>Request sent</AlertTitle>
                <AlertContent>{`Thanks, ${requested}. Marta usually replies within an hour.`}</AlertContent>
              </AlertMain>
            </Alert>
          )}

          <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
            <div style={field}>
              <Label htmlFor={`${id}-stay`}>Check-in – check-out</Label>
              <DateRangeField
                format='MM/dd/yyyy'
                separator='/'
                disabledDates={BOOKED}
                error={stayError !== ''}
                value={stay}
                onChangeValue={changeStay}
              >
                <DateRangeFieldInput
                  id={`${id}-stay`}
                  name='stay'
                  placeholder='mm/dd/yyyy – mm/dd/yyyy'
                  aria-invalid={stayError !== ''}
                  aria-describedby={`${id}-stay-hint`}
                />
                <Popover placement='bottom-end'>
                  <PopoverTrigger asChild>
                    <DateRangeFieldTriggerIconButton aria-label='Choose stay dates' />
                  </PopoverTrigger>
                  <PopoverContent>
                    <DateRangeFieldCalendar>
                      <CalendarHeader>
                        <CalendarPrevButton aria-label='Previous month' />
                        <CalendarViewButtonGroup>
                          <CalendarViewButton view='month'><CalendarMonthLabel /></CalendarViewButton>
                          <CalendarViewButton view='year'><CalendarYearLabel /></CalendarViewButton>
                        </CalendarViewButtonGroup>
                        <CalendarNextButton aria-label='Next month' />
                      </CalendarHeader>
                      <CalendarContent>
                        <CalendarDays>
                          <CalendarWeekdays>
                            <CalendarWeekday />
                          </CalendarWeekdays>
                          <CalendarWeeks>
                            <CalendarDay />
                          </CalendarWeeks>
                        </CalendarDays>
                        <CalendarMonths>
                          <CalendarMonth />
                        </CalendarMonths>
                        <CalendarYears>
                          <CalendarYear />
                        </CalendarYears>
                      </CalendarContent>
                    </DateRangeFieldCalendar>
                  </PopoverContent>
                </Popover>
              </DateRangeField>
              <HelperText id={`${id}-stay-hint`} error={stayError !== ''}>
                {stayError === '' ? 'Booked Nov 20–23. Two nights minimum.' : stayError}
              </HelperText>
            </div>

            <div style={field}>
              <Label htmlFor={`${id}-guests`}>Guests</Label>
              <NumberField min={1} max={4} value={guests} onChangeValue={(next) => setGuests(next ?? 1)}>
                <NumberFieldDecrementButton aria-label='Remove a guest' />
                <NumberFieldInput id={`${id}-guests`} name='guests' aria-describedby={`${id}-guests-hint`} />
                <NumberFieldIncrementButton aria-label='Add a guest' />
              </NumberField>
              <HelperText id={`${id}-guests-hint`}>Up to 4 guests. Infants stay free.</HelperText>
            </div>
          </div>

          {nights > 0 && (
            <div style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
              <div style={row}>
                <Text size={2}>{`${currency.format(NIGHTLY_RATE)} × ${nights} ${nights === 1 ? 'night' : 'nights'}`}</Text>
                <Text size={2} style={amount}>{currency.format(lodging)}</Text>
              </div>
              <div style={row}>
                <Text size={2}>Cleaning fee</Text>
                <Text size={2} style={amount}>{currency.format(CLEANING_FEE)}</Text>
              </div>
              <div style={row}>
                <Text size={2}>Service fee</Text>
                <Text size={2} style={amount}>{currency.format(service)}</Text>
              </div>
              <Separator style={{ marginBlock: 'var(--spacing-2)' }} />
              <div style={row}>
                <Text size={3} fontWeight={700}>Total</Text>
                <Text size={3} fontWeight={700} aria-live='polite' style={amount}>{currency.format(total)}</Text>
              </div>
            </div>
          )}

          <Button type='submit' size='sm' fullWidth>Reserve</Button>
          <Text size={2} style={{ color: 'var(--label-secondary)', textAlign: 'center' }}>You won’t be charged until Marta accepts.</Text>
        </form>
      </Card>

      <Sheet open={open} onChangeOpen={setOpen}>
        <SheetContent aria-labelledby={`${id}-sheet-title`} aria-describedby={`${id}-sheet-summary`}>
          <SheetHandle aria-label='Resize sheet' />
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-sheet-title`} as='h2' size={4} fontWeight={600}>Request to book</Heading>
            <SheetClose asChild>
              <IconButton size='xs' aria-label='Close' variant='secondary' appearance='ghost' style={closeButton}>
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </SheetClose>
          </div>
          <Text id={`${id}-sheet-summary`} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-2)', color: 'var(--label-secondary)', ...amount, whiteSpace: 'normal' }}>
            {nights > 0 ? `${formatStay(stay)} · ${guests} ${guests === 1 ? 'guest' : 'guests'} · ${currency.format(total)} total` : ''}
          </Text>
          <form noValidate onSubmit={sendRequest} style={{ display: 'grid', gap: 'var(--spacing-8)', marginBlockStart: 'var(--spacing-10)' }}>
            <div style={field}>
              <Label htmlFor={`${id}-guest-name`}>Full name</Label>
              <TextField error={guestErrors.name !== undefined}>
                <TextFieldInput
                  id={`${id}-guest-name`}
                  name='name'
                  autoComplete='name'
                  aria-invalid={guestErrors.name !== undefined}
                  aria-describedby={guestErrors.name ? `${id}-guest-name-error` : undefined}
                />
              </TextField>
              {guestErrors.name && <HelperText id={`${id}-guest-name-error`} error>{guestErrors.name}</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-guest-email`}>Email</Label>
              <TextField error={guestErrors.email !== undefined}>
                <TextFieldInput
                  id={`${id}-guest-email`}
                  name='email'
                  type='email'
                  autoComplete='email'
                  aria-invalid={guestErrors.email !== undefined}
                  aria-describedby={guestErrors.email ? `${id}-guest-email-error` : undefined}
                />
              </TextField>
              {guestErrors.email && <HelperText id={`${id}-guest-email-error`} error>{guestErrors.email}</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-message`}>Message to Marta (optional)</Label>
              <TextArea id={`${id}-message`} name='message' rows={3} maxLength={500} fullWidth placeholder='Arrival time, questions about the area…' />
            </div>
            <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-4)' }}>
              <SheetClose asChild>
                <Button size='xs' type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </SheetClose>
              <Button size='xs' type='submit'>Send request</Button>
            </div>
          </form>
        </SheetContent>
      </Sheet>
    </div>
  );
}

Использованные компоненты