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

Запись на встречу

Длительность встречи, день и время, контактные данные и добавление события в календарь.

Код

docs/examples/templates/event-scheduling.tsx
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import {
  Avatar,
  AvatarFallback,
  Button,
  Calendar,
  CalendarContent,
  CalendarDay,
  CalendarDays,
  CalendarHeader,
  CalendarMonth,
  CalendarMonthLabel,
  CalendarMonths,
  CalendarNextButton,
  CalendarPrevButton,
  CalendarViewButton,
  CalendarViewButtonGroup,
  CalendarWeekday,
  CalendarWeekdays,
  CalendarWeeks,
  CalendarYear,
  CalendarYearLabel,
  CalendarYears,
  Card,
  Heading,
  HelperText,
  Icon,
  Label,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Separator,
  Text,
  TextArea,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { CalendarDays as CalendarIcon, CircleCheck, Clock, Globe, Video } from '@vega-ui/icons';

const HOST_ZONE = 'Europe/Berlin';
const FIRST_DAY = '2026-11-09';
const LAST_DAY = '2026-12-23';
const FULLY_BOOKED = ['2026-11-11', '2026-11-19', '2026-12-02'];
const lengths = [15, 30, 60];

const timezones = [
  { value: 'Europe/Berlin', label: 'Berlin (CET)' },
  { value: 'Europe/London', label: 'London (GMT)' },
  { value: 'America/New_York', label: 'New York (ET)' },
  { value: 'America/Los_Angeles', label: 'Los Angeles (PT)' },
];

const dayTitle = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC' });

const field = { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const meta = { display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', color: 'var(--label-secondary)' } as const;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const isUnavailable = (date: string) => {
  const weekday = new Date(date).getUTCDay();
  if (weekday === 0 || weekday === 6) return true;

  return FULLY_BOOKED.includes(date);
};

const zoneOffset = (timeZone: string, instant: number) => {
  const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
    .formatToParts(instant)
    .find((part) => part.type === 'timeZoneName')?.value ?? '';
  const match = name.match(/([+-])(\d{2}):(\d{2})/);
  if (!match) return 0;

  const minutes = Number(match[2]) * 60 + Number(match[3]);

  return match[1] === '-' ? -minutes : minutes;
};

const getSlots = (date: string, length: number) => {
  const midnight = Date.parse(date);
  const offset = zoneOffset(HOST_ZONE, midnight + 12 * 3_600_000);
  const day = new Date(date).getUTCDate();
  const slots: number[] = [];

  for (let minutes = 9 * 60; minutes + length <= 17 * 60; minutes += length) {
    if (minutes === 12 * 60 || (day + minutes / length) % 4 === 0) continue;
    slots.push(midnight + (minutes - offset) * 60_000);
  }

  return slots;
};

const compact = (instant: number) => new Date(instant).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');

const calendarLinks = (start: number, end: number, title: string) => {
  const text = encodeURIComponent(title);
  const ics = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'BEGIN:VEVENT',
    `DTSTART:${compact(start)}`,
    `DTEND:${compact(end)}`,
    `SUMMARY:${title}`,
    'LOCATION:Google Meet',
    'END:VEVENT',
    'END:VCALENDAR',
  ].join('\r\n');

  return {
    google: `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${text}&dates=${compact(start)}/${compact(end)}`,
    outlook: `https://outlook.live.com/calendar/0/deeplink/compose?subject=${text}&startdt=${new Date(start).toISOString()}&enddt=${new Date(end).toISOString()}`,
    ics: `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`,
  };
};

type Step = 'pick' | 'details' | 'done';
type Errors = Partial<Record<string, string>>;

const validateDetails = (data: FormData) => {
  const errors: Errors = {};
  const name = String(data.get('name')).trim();
  const email = String(data.get('email')).trim();
  const guests = String(data.get('guests')).split(',').map((guest) => guest.trim()).filter((guest) => guest !== '');

  if (name === '') errors.name = 'Enter your name so Maya knows who is joining.';
  if (email === '') errors.email = 'Enter an email for the invitation.';
  if (email !== '' && !emailPattern.test(email)) errors.email = 'Use the full address, like ada@example.com.';
  if (guests.length > 5) errors.guests = 'Invite up to 5 guests.';
  if (guests.some((guest) => !emailPattern.test(guest))) errors.guests = 'Separate guest emails with commas, like leo@acme.io, mia@acme.io.';

  return errors;
};

export default function EventScheduling() {
  const id = useId();
  const headingRef = useRef<HTMLHeadingElement>(null);
  const slotsRef = useRef<HTMLDivElement>(null);
  const calendarRef = useRef<HTMLDivElement>(null);
  const moved = useRef(false);
  const [step, setStep] = useState<Step>('pick');
  const [length, setLength] = useState(30);
  const [date, setDate] = useState('');
  const [time, setTime] = useState<number | null>(null);
  const [timezone, setTimezone] = useState('Europe/Berlin');
  const [timeError, setTimeError] = useState('');
  const [errors, setErrors] = useState<Errors>({});
  const [booked, setBooked] = useState({ name: '', email: '' });

  useEffect(() => {
    if (!moved.current) return;
    moved.current = false;
    headingRef.current?.focus();
  }, [step]);

  const timeFormat = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZone: timezone });
  const slots = date === '' ? [] : getSlots(date, length);
  const end = time === null ? 0 : time + length * 60_000;
  const when = time === null ? '' : `${dayTitle.format(Date.parse(date))} · ${timeFormat.format(time)} – ${timeFormat.format(end)}`;
  const timezoneLabel = timezones.find((item) => item.value === timezone)?.label ?? '';
  const title = `${length}-minute design review with Maya Kovač`;

  const goTo = (next: Step) => {
    moved.current = true;
    setStep(next);
  };

  const changeDate = (next: string) => {
    setDate(next);
    setTime(null);
    setTimeError('');
  };

  const changeLength = (next: number) => {
    setLength(next);
    setTime(null);
  };

  const chooseTime = (slot: number) => {
    setTime(slot);
    setTimeError('');
  };

  const next = () => {
    if (time !== null) {
      setErrors({});
      goTo('details');
      return;
    }

    if (date === '') {
      setTimeError('Choose a day, then a time.');
      calendarRef.current?.querySelector<HTMLElement>('[tabindex="0"]')?.focus();
      return;
    }

    setTimeError('Choose a time to continue.');
    slotsRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
  };

  const clearError = (name: string) => {
    if (!errors[name]) return;
    setErrors({ ...errors, [name]: undefined });
  };

  const describe = (name: string) => errors[name] ? `${id}-${name}-error` : undefined;

  const schedule = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    const nextErrors = validateDetails(data);
    const invalid = ['name', 'email', 'guests'].find((name) => nextErrors[name]);

    setErrors(nextErrors);

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

    setBooked({ name: String(data.get('name')).trim().split(' ')[0], email: String(data.get('email')).trim() });
    goTo('done');
  };

  const startOver = () => {
    setDate('');
    setTime(null);
    goTo('pick');
  };

  const host = (
    <Card style={{ alignItems: 'stretch', gap: 'var(--spacing-10)', flex: '1 1 260px', minWidth: 0 }} aria-labelledby={`${id}-event`}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)', minWidth: 0 }}>
        <Avatar size='lg' aria-hidden='true' style={{ flexShrink: 0 }}>
          <AvatarFallback>MK</AvatarFallback>
        </Avatar>
        <div style={{ display: 'grid', minWidth: 0 }}>
          <Text size={3} fontWeight={500}>Maya Kovač</Text>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Design lead, Northwind</Text>
        </div>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Heading id={`${id}-event`} as='h2' size={5} fontWeight={700}>Design review</Heading>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Bring a link to your file. We’ll go through flows, edge cases and next steps.</Text>
      </div>
      <ul style={{ display: 'grid', gap: 'var(--spacing-5)', margin: 0, padding: 0, listStyle: 'none' }}>
        <li style={meta}>
          <Icon size='sm' aria-hidden='true' style={{ flexShrink: 0 }}><Clock /></Icon>
          <Text size={2} style={{ fontVariantNumeric: 'tabular-nums' }}>{`${length} min`}</Text>
        </li>
        <li style={meta}>
          <Icon size='sm' aria-hidden='true' style={{ flexShrink: 0 }}><Video /></Icon>
          <Text size={2}>Google Meet</Text>
        </li>
        {step !== 'pick' && (
          <>
            <li style={{ ...meta, color: 'var(--label-primary)' }}>
              <Icon size='sm' aria-hidden='true' style={{ flexShrink: 0 }}><CalendarIcon /></Icon>
              <Text size={2} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>{when}</Text>
            </li>
            <li style={meta}>
              <Icon size='sm' aria-hidden='true' style={{ flexShrink: 0 }}><Globe /></Icon>
              <Text size={2}>{timezoneLabel}</Text>
            </li>
          </>
        )}
      </ul>
    </Card>
  );

  if (step === 'done') {
    const links = calendarLinks(time ?? 0, end, title);

    return (
      <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 560, marginInline: 'auto' }}>
        <header style={{ display: 'grid', gap: 'var(--spacing-4)', justifyItems: 'start' }}>
          <Icon size='lg' aria-hidden='true' style={{ color: 'var(--color-green-accent-700)' }}><CircleCheck /></Icon>
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <Heading ref={headingRef} tabIndex={-1} as='h1' size={6} fontWeight={700} style={{ outline: 'none' }}>{`You’re scheduled, ${booked.name}`}</Heading>
            <Text size={3} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{`An invitation is on its way to ${booked.email}.`}</Text>
          </div>
        </header>
        {host}
        <div style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
          <Text id={`${id}-add`} size={3} fontWeight={500}>Add to calendar</Text>
          <div role='group' aria-labelledby={`${id}-add`} style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-4)' }}>
            <Button size='xs' asChild variant='secondary' appearance='ghost'>
              <a href={links.google} target='_blank' rel='noreferrer'>Google Calendar</a>
            </Button>
            <Button size='xs' asChild variant='secondary' appearance='ghost'>
              <a href={links.outlook} target='_blank' rel='noreferrer'>Outlook</a>
            </Button>
            <Button size='xs' asChild variant='secondary' appearance='ghost'>
              <a href={links.ics} download='design-review.ics'>Apple Calendar (.ics)</a>
            </Button>
          </div>
        </div>
        <Separator />
        <Button size='xs' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }} onClick={startOver}>Schedule another meeting</Button>
      </div>
    );
  }

  if (step === 'details') {
    return (
      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
        {host}
        <form noValidate aria-labelledby={`${id}-details`} onSubmit={schedule} style={{ display: 'grid', gap: 'var(--spacing-12)', flex: '999 1 360px', minWidth: 0 }}>
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <Heading ref={headingRef} tabIndex={-1} id={`${id}-details`} as='h1' size={6} fontWeight={700} style={{ outline: 'none' }}>Your details</Heading>
            <Text size={3} style={{ color: 'var(--label-secondary)' }}>We’ll send the invitation and a reminder an hour before.</Text>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 200px), 1fr))', gap: 'var(--spacing-8)' }}>
              <div style={field}>
                <Label htmlFor={`${id}-name`}>Name</Label>
                <TextField error={errors.name !== undefined}>
                  <TextFieldInput
                    id={`${id}-name`}
                    name='name'
                    autoComplete='name'
                    aria-invalid={errors.name !== undefined}
                    aria-describedby={describe('name')}
                    onChange={() => clearError('name')}
                  />
                </TextField>
                {errors.name && <HelperText id={`${id}-name-error`} error>{errors.name}</HelperText>}
              </div>
              <div style={field}>
                <Label htmlFor={`${id}-email`}>Email</Label>
                <TextField error={errors.email !== undefined}>
                  <TextFieldInput
                    id={`${id}-email`}
                    name='email'
                    type='email'
                    autoComplete='email'
                    aria-invalid={errors.email !== undefined}
                    aria-describedby={describe('email')}
                    onChange={() => clearError('email')}
                  />
                </TextField>
                {errors.email && <HelperText id={`${id}-email-error`} error>{errors.email}</HelperText>}
              </div>
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-guests`}>Guests (optional)</Label>
              <TextField error={errors.guests !== undefined}>
                <TextFieldInput
                  id={`${id}-guests`}
                  name='guests'
                  autoComplete='off'
                  placeholder='leo@acme.io, mia@acme.io'
                  aria-invalid={errors.guests !== undefined}
                  aria-describedby={errors.guests ? `${id}-guests-error` : `${id}-guests-hint`}
                  onChange={() => clearError('guests')}
                />
              </TextField>
              {errors.guests
                ? <HelperText id={`${id}-guests-error`} error>{errors.guests}</HelperText>
                : <HelperText id={`${id}-guests-hint`}>Up to 5 emails, separated by commas.</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-notes`}>Anything Maya should know? (optional)</Label>
              <TextArea id={`${id}-notes`} name='notes' rows={4} maxLength={500} fullWidth placeholder='Link to the file, the questions you want to cover…' />
            </div>
          </div>
          <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
            <Button size='xs' type='button' variant='secondary' appearance='ghost' onClick={() => goTo('pick')}>Back</Button>
            <Button size='xs' type='submit'>Schedule meeting</Button>
          </div>
        </form>
      </div>
    );
  }

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
        <Heading ref={headingRef} tabIndex={-1} as='h1' size={7} fontWeight={700} style={{ outline: 'none' }}>Book a design review</Heading>
        <Text size={3} style={{ color: 'var(--label-secondary)' }}>Pick a length, a day and a time that work for you.</Text>
      </header>

      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 'var(--spacing-16) var(--spacing-14)' }}>
        <div style={{ display: 'grid', flex: '1 1 220px', minWidth: 0 }}>
          {host}
        </div>

        <div ref={calendarRef} style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 'var(--spacing-4)', flex: '1 1 280px', maxWidth: 'min(100%, 340px)', minWidth: 0 }}>
          <Calendar
            locale='en-US'
            min={FIRST_DAY}
            max={LAST_DAY}
            defaultMonth={FIRST_DAY}
            disabledDates={isUnavailable}
            value={date}
            onChangeValue={changeDate}
          >
            <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 aria-label='Day' aria-describedby={`${id}-days-hint`}>
                <CalendarWeekdays>
                  <CalendarWeekday />
                </CalendarWeekdays>
                <CalendarWeeks>
                  <CalendarDay />
                </CalendarWeeks>
              </CalendarDays>
              <CalendarMonths>
                <CalendarMonth />
              </CalendarMonths>
              <CalendarYears>
                <CalendarYear />
              </CalendarYears>
            </CalendarContent>
          </Calendar>
          <HelperText id={`${id}-days-hint`}>Weekdays until December 23.</HelperText>
        </div>

        <section aria-labelledby={`${id}-times`} style={{ display: 'grid', gap: 'var(--spacing-8)', flex: '1 1 240px', minWidth: 0 }}>
          <div style={field}>
            <Label id={`${id}-length`}>Meeting length</Label>
            <SegmentedControl
              name={`${id}-length`}
              role='radiogroup'
              aria-labelledby={`${id}-length`}
              value={length}
              size='sm'
              style={{ width: '100%' }}
              onChange={(event) => changeLength(Number(event.currentTarget.value))}
            >
              {lengths.map((option) => (
                <SegmentedControlItem key={option} value={option} style={{ flex: 1, fontVariantNumeric: 'tabular-nums' }}>
                  <SegmentedControlItemInput />
                  {`${option} min`}
                </SegmentedControlItem>
              ))}
              <SegmentedControlIndicator />
            </SegmentedControl>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-1)', marginBlockStart: 'var(--spacing-4)' }}>
            <Heading id={`${id}-times`} as='h2' size={4} fontWeight={600}>{date === '' ? 'Available times' : dayTitle.format(Date.parse(date))}</Heading>
            <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
              {date === '' ? 'Pick a day to see open times.' : `${slots.length} open ${slots.length === 1 ? 'time' : 'times'}`}
            </Text>
          </div>
          {slots.length > 0 && (
            <div
              ref={slotsRef}
              role='group'
              aria-labelledby={`${id}-times`}
              aria-describedby={timeError ? `${id}-time-error` : undefined}
              style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(96px, 1fr))', gap: 'var(--spacing-4)' }}
            >
              {slots.map((slot) => (
                <Button
                  key={slot}
                  type='button'
                  size='xs'
                  variant={time === slot ? 'primary' : 'secondary'}
                  appearance={time === slot ? 'fill' : 'ghost'}
                  aria-pressed={time === slot}
                  onClick={() => chooseTime(slot)}
                  style={{ fontVariantNumeric: 'tabular-nums' }}
                >
                  {timeFormat.format(slot)}
                </Button>
              ))}
            </div>
          )}
          {timeError && <HelperText id={`${id}-time-error`} error>{timeError}</HelperText>}
          <div style={field}>
            <Label htmlFor={`${id}-timezone`}>Time zone</Label>
            <Select value={timezone} onChangeValue={(next) => setTimezone(next ?? 'Europe/Berlin')}>
              <SelectHiddenSelect name='timezone' />
              <SelectCombobox id={`${id}-timezone`}>
                <SelectValue />
                <SelectIcon />
              </SelectCombobox>
              <SelectListbox>
                {timezones.map((item) => (
                  <SelectOption key={item.value} value={item.value}>{item.label}</SelectOption>
                ))}
              </SelectListbox>
            </Select>
          </div>
          <Button size='xs' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-4)' }} onClick={next}>Next</Button>
        </section>
      </div>
    </div>
  );
}

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