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

Оформление заказа

Контакты и адрес доставки, выбор страны, способы доставки и состав заказа с живыми итогами.

Код

docs/examples/templates/checkout.tsx
import { useId, useRef, useState, type FormEvent } from 'react';
import {
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Button,
  Card,
  Fieldset,
  FieldsetLegend,
  Heading,
  HelperText,
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  Radio,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Table,
  TableBody,
  TableData,
  TableFoot,
  TableHeading,
  TableRow,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { FlagCA, FlagDE, FlagFR, FlagGBUKM, FlagNL, FlagUS } from '@vega-ui/icons';

const countries = [
  { code: 'US', name: 'United States', callingCode: '+1', Flag: FlagUS },
  { code: 'CA', name: 'Canada', callingCode: '+1', Flag: FlagCA },
  { code: 'GB', name: 'United Kingdom', callingCode: '+44', Flag: FlagGBUKM },
  { code: 'DE', name: 'Germany', callingCode: '+49', Flag: FlagDE },
  { code: 'FR', name: 'France', callingCode: '+33', Flag: FlagFR },
  { code: 'NL', name: 'Netherlands', callingCode: '+31', Flag: FlagNL },
] as const;

const shippingOptions = [
  { value: 'standard', title: 'Standard', eta: '5–7 business days', price: 0 },
  { value: 'express', title: 'Express', eta: '2–3 business days', price: 12 },
  { value: 'overnight', title: 'Next day', eta: 'Order before 2 pm', price: 24 },
];

const items = [
  { name: 'Merino crew sweater', variant: 'Oat · M', quantity: 1, price: 98 },
  { name: 'Organic cotton tee', variant: 'Black · M', quantity: 2, price: 32 },
  { name: 'Wool hiking socks', variant: '3-pack', quantity: 1, price: 24 },
];

const TAX_RATE = 0.08;

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const columns = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 180px), 1fr))', gap: 'var(--spacing-8)' } as const;
const amount = { fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' } as const;
const fieldOrder = ['email', 'phone', 'country', 'name', 'address', 'city', 'postal'];
const legend = { fontWeight: 600, marginBlockEnd: 'var(--spacing-6)' } as const;

type Errors = Partial<Record<string, string>>;

const validate = (data: FormData, country: string | null) => {
  const errors: Errors = {};
  const value = (name: string) => String(data.get(name) ?? '').trim();

  if (value('email') === '') errors.email = 'Enter an email for the receipt.';
  if (value('email') !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value('email'))) errors.email = 'Use the full address, like ada@example.com.';
  if (value('phone').replace(/\D/g, '').length < 7) errors.phone = 'Enter a phone number so the courier can reach you.';
  if (value('name') === '') errors.name = 'Enter the name of the person receiving the order.';
  if (value('address') === '') errors.address = 'Enter a street address.';
  if (value('city') === '') errors.city = 'Enter a city.';
  if (value('postal') === '') errors.postal = 'Enter a postal code.';
  if (country === null) errors.country = 'Choose a country to see delivery options.';

  return errors;
};

export default function Checkout() {
  const id = useId();
  const countryRef = useRef<HTMLButtonElement>(null);
  const [phoneCountry, setPhoneCountry] = useState<(typeof countries)[number]>(countries[0]);
  const [country, setCountry] = useState<string | null>(null);
  const [shipping, setShipping] = useState('standard');
  const [errors, setErrors] = useState<Errors>({});
  const [placed, setPlaced] = useState('');

  const subtotal = items.reduce((sum, item) => sum + item.quantity * item.price, 0);
  const delivery = shippingOptions.find((option) => option.value === shipping)?.price ?? 0;
  const tax = subtotal * TAX_RATE;
  const total = subtotal + delivery + tax;

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

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

  const changePhoneCountry = (code: string) => {
    const found = countries.find((item) => item.code === code);
    if (found) setPhoneCountry(found);
  };

  const changeCountry = (next: string | undefined) => {
    setCountry(next ?? null);
    clearError('country');
  };

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    const next = validate(data, country);
    const invalid = fieldOrder.find((name) => next[name]);

    setErrors(next);

    if (invalid === 'country') {
      countryRef.current?.focus();
      return;
    }

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

    setPlaced(String(data.get('email')));
  };

  if (placed !== '') {
    return (
      <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 560, marginInline: 'auto' }}>
        <Alert variant='success'>
          <AlertIcon />
          <AlertMain>
            <AlertTitle>Order #10482 is confirmed</AlertTitle>
            <AlertContent>{`We sent the receipt to ${placed}. You will get a tracking link when it ships.`}</AlertContent>
          </AlertMain>
        </Alert>
        <Button size='xs' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }} onClick={() => setPlaced('')}>Back to checkout</Button>
      </div>
    );
  }

  return (
    <form
      noValidate
      aria-labelledby={`${id}-title`}
      onSubmit={submit}
      style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}
    >
      <div style={{ display: 'grid', gap: 'var(--spacing-16)', flex: '999 1 420px', minWidth: 0 }}>
        <header style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <Heading id={`${id}-title`} as='h1' size={7} fontWeight={700}>Checkout</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>Free returns within 30 days.</Text>
        </header>

        <Fieldset style={{ gap: 'var(--spacing-8)' }}>
          <FieldsetLegend style={legend}>Contact</FieldsetLegend>
          <div style={columns}>
            <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 style={field}>
              <Label htmlFor={`${id}-phone`}>Phone</Label>
              <PhoneField code={phoneCountry.code} onChangeCode={changePhoneCountry} error={errors.phone !== undefined}>
                <PhoneFieldSelect>
                  <PhoneFieldSelectHiddenSelect name='phoneCountry' />
                  <PhoneFieldSelectCombobox aria-label={`Country code: ${phoneCountry.name}`}>
                    <PhoneFieldSelectValue>
                      <phoneCountry.Flag width={20} height={15} aria-hidden='true' />
                    </PhoneFieldSelectValue>
                    <PhoneFieldSelectIcon />
                  </PhoneFieldSelectCombobox>
                  <PhoneFieldSelectListbox>
                    {countries.map(({ code, name, callingCode, Flag }) => (
                      <PhoneFieldSelectOption key={code} value={code} label={name}>
                        <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
                          <Flag width={20} height={15} aria-hidden='true' />
                          {name}
                        </span>
                        <span style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{callingCode}</span>
                      </PhoneFieldSelectOption>
                    ))}
                  </PhoneFieldSelectListbox>
                </PhoneFieldSelect>
                <PhoneFieldInput
                  id={`${id}-phone`}
                  name='phone'
                  autoComplete='tel'
                  aria-invalid={errors.phone !== undefined}
                  aria-describedby={describe('phone')}
                  onChange={() => clearError('phone')}
                />
              </PhoneField>
              {errors.phone && <HelperText id={`${id}-phone-error`} error>{errors.phone}</HelperText>}
            </div>
          </div>
        </Fieldset>

        <Fieldset style={{ gap: 'var(--spacing-8)' }}>
          <FieldsetLegend style={legend}>Shipping address</FieldsetLegend>
          <div style={field}>
            <Label htmlFor={`${id}-country`}>Country</Label>
            <Select value={country} onChangeValue={changeCountry}>
              <SelectHiddenSelect name='country' />
              <SelectCombobox
                id={`${id}-country`}
                ref={countryRef}
                aria-invalid={errors.country !== undefined}
                aria-describedby={describe('country')}
              >
                <SelectValue placeholder='Choose a country' />
                <SelectIcon />
              </SelectCombobox>
              <SelectListbox>
                {countries.map((item) => (
                  <SelectOption key={item.code} value={item.code}>{item.name}</SelectOption>
                ))}
              </SelectListbox>
            </Select>
            {errors.country && <HelperText id={`${id}-country-error`} error>{errors.country}</HelperText>}
          </div>
          <div style={field}>
            <Label htmlFor={`${id}-name`}>Full name</Label>
            <TextField error={errors.name !== undefined}>
              <TextFieldInput
                id={`${id}-name`}
                name='name'
                autoComplete='shipping 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}-address`}>Address</Label>
            <TextField error={errors.address !== undefined}>
              <TextFieldInput
                id={`${id}-address`}
                name='address'
                autoComplete='shipping address-line1'
                aria-invalid={errors.address !== undefined}
                aria-describedby={describe('address')}
                onChange={() => clearError('address')}
              />
            </TextField>
            {errors.address && <HelperText id={`${id}-address-error`} error>{errors.address}</HelperText>}
          </div>
          <div style={field}>
            <Label htmlFor={`${id}-apartment`}>Apartment, suite (optional)</Label>
            <TextField>
              <TextFieldInput id={`${id}-apartment`} name='apartment' autoComplete='shipping address-line2' />
            </TextField>
          </div>
          <div style={columns}>
            <div style={field}>
              <Label htmlFor={`${id}-city`}>City</Label>
              <TextField error={errors.city !== undefined}>
                <TextFieldInput
                  id={`${id}-city`}
                  name='city'
                  autoComplete='shipping address-level2'
                  aria-invalid={errors.city !== undefined}
                  aria-describedby={describe('city')}
                  onChange={() => clearError('city')}
                />
              </TextField>
              {errors.city && <HelperText id={`${id}-city-error`} error>{errors.city}</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-postal`}>Postal code</Label>
              <TextField error={errors.postal !== undefined}>
                <TextFieldInput
                  id={`${id}-postal`}
                  name='postal'
                  autoComplete='shipping postal-code'
                  aria-invalid={errors.postal !== undefined}
                  aria-describedby={describe('postal')}
                  onChange={() => clearError('postal')}
                />
              </TextField>
              {errors.postal && <HelperText id={`${id}-postal-error`} error>{errors.postal}</HelperText>}
            </div>
          </div>
        </Fieldset>

        <Fieldset style={{ gap: 'var(--spacing-4)' }}>
          <FieldsetLegend style={legend}>Shipping method</FieldsetLegend>
          {shippingOptions.map((option) => (
            <label
              key={option.value}
              htmlFor={`${id}-${option.value}`}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 'var(--spacing-6)',
                padding: 'var(--spacing-7) var(--spacing-8)',
                border: '1px solid',
                borderColor: shipping === option.value ? 'var(--color-primary-500)' : 'var(--separator-opaque)',
                borderRadius: 'var(--radius-4)',
                background: 'var(--surface-primary)',
                cursor: 'pointer',
              }}
            >
              <Radio
                id={`${id}-${option.value}`}
                name='shipping'
                value={option.value}
                checked={shipping === option.value}
                aria-describedby={`${id}-${option.value}-eta`}
                onChange={() => setShipping(option.value)}
                style={{ flexShrink: 0 }}
              />
              <span style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
                <Text size={3} fontWeight={500}>{option.title}</Text>
                <Text id={`${id}-${option.value}-eta`} size={2} style={{ color: 'var(--label-secondary)' }}>{option.eta}</Text>
              </span>
              <Text size={3} fontWeight={500} style={{ ...amount, marginInlineStart: 'auto' }}>
                {option.price === 0 ? 'Free' : currency.format(option.price)}
              </Text>
            </label>
          ))}
        </Fieldset>
      </div>

      <Card style={{ alignItems: 'stretch', gap: 'var(--spacing-10)', flex: '1 1 300px', minWidth: 0 }} aria-labelledby={`${id}-summary`}>
        <Heading id={`${id}-summary`} as='h2' size={4} fontWeight={600}>Order summary</Heading>
        <Table fullWidth style={{ borderStyle: 'hidden' }}>
          <TableBody>
            {items.map((item) => (
              <TableRow key={item.name}>
                <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400 }}>
                  <span style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
                    <Text size={2} fontWeight={500}>{item.name}</Text>
                    <Text size={2} style={{ color: 'var(--label-secondary)' }}>{`${item.variant} · Qty ${item.quantity}`}</Text>
                  </span>
                </TableHeading>
                <TableData dataAlign='end' style={amount}>{currency.format(item.quantity * item.price)}</TableData>
              </TableRow>
            ))}
          </TableBody>
          <TableFoot>
            <TableRow>
              <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400 }}>Subtotal</TableHeading>
              <TableData dataAlign='end' style={amount}>{currency.format(subtotal)}</TableData>
            </TableRow>
            <TableRow>
              <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400 }}>Shipping</TableHeading>
              <TableData dataAlign='end' style={amount}>{delivery === 0 ? 'Free' : currency.format(delivery)}</TableData>
            </TableRow>
            <TableRow>
              <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400 }}>Estimated tax</TableHeading>
              <TableData dataAlign='end' style={amount}>{currency.format(tax)}</TableData>
            </TableRow>
            <TableRow>
              <TableHeading scope='row' dataAlign='start'>Total</TableHeading>
              <TableData dataAlign='end' aria-live='polite' style={{ ...amount, fontWeight: 600 }}>{currency.format(total)}</TableData>
            </TableRow>
          </TableFoot>
        </Table>
        <Button type='submit' size='sm' fullWidth>{`Pay ${currency.format(total)}`}</Button>
        <Text size={2} style={{ color: 'var(--label-secondary)', textAlign: 'center' }}>You won’t be charged until the order ships.</Text>
      </Card>
    </form>
  );
}

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