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

Центр помощи

Живой поиск по статьям, популярные темы, вопросы по разделам, статус систем и форма обращения.

Код

docs/examples/templates/help-center.tsx
import { useId, useRef, useState, type FormEvent } from 'react';
import {
  Accordion,
  AccordionContent,
  AccordionHeader,
  AccordionIcon,
  AccordionItem,
  AccordionTrigger,
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Badge,
  Button,
  Card,
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  Heading,
  HelperText,
  Icon,
  Label,
  Select,
  SelectCombobox,
  SelectHiddenSelect,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Separator,
  Text,
  TextArea,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { CreditCard, KeyRound, Plug, Search } from '@vega-ui/icons';
import './help-center.css';

const MESSAGE_LIMIT = 1000;

const categories = [
  {
    value: 'billing',
    title: 'Billing and plans',
    icon: <CreditCard />,
    articles: [
      { value: 'invoice', question: 'Where can I download my invoices?', answer: 'Open Settings → Billing → Invoices. Every invoice is available as a PDF for seven years, and owners can add a VAT number that appears on future invoices.' },
      { value: 'downgrade', question: 'What happens when I downgrade my plan?', answer: 'The change applies at the end of the billing period. Projects over the new limit become read-only until you archive them or upgrade again.' },
      { value: 'refund', question: 'Can I get a refund?', answer: 'Annual plans can be refunded within 14 days of purchase. Monthly plans are not refunded, but you can cancel any time before the next renewal.' },
    ],
  },
  {
    value: 'account',
    title: 'Account and sign-in',
    icon: <KeyRound />,
    articles: [
      { value: 'reset', question: 'I forgot my password. How do I reset it?', answer: 'Choose “Forgot password” on the sign-in page and enter your work email. The reset link is valid for one hour.' },
      { value: 'two-factor', question: 'How do I turn on two-factor authentication?', answer: 'Go to Settings → Security and choose an authenticator app or a security key. Keep the recovery codes somewhere safe.' },
      { value: 'email', question: 'Can I change the email I sign in with?', answer: 'Yes. Update it in Settings → Profile; we send a confirmation link to the new address before the change takes effect.' },
    ],
  },
  {
    value: 'integrations',
    title: 'Integrations',
    icon: <Plug />,
    articles: [
      { value: 'slack', question: 'How do I connect Slack?', answer: 'Open Integrations → Slack and choose “Connect”. A workspace admin may need to approve the app before notifications start.' },
      { value: 'webhooks', question: 'Why are my webhooks delayed?', answer: 'Deliveries retry with backoff for up to 24 hours when your endpoint fails. Check the delivery log for response codes and the last attempt.' },
      { value: 'api-keys', question: 'Where do I create an API key?', answer: 'API keys live in Settings → Developers. Each key can be scoped to one project and revoked at any time.' },
    ],
  },
];

const services = [
  { name: 'API', status: 'Operational', variant: 'success' },
  { name: 'Dashboard', status: 'Operational', variant: 'success' },
  { name: 'Webhooks', status: 'Degraded', variant: 'warning' },
  { name: 'Email delivery', status: 'Operational', variant: 'success' },
] as const;

const topics = [
  { value: 'billing', label: 'Billing' },
  { value: 'account', label: 'Account and sign-in' },
  { value: 'integrations', label: 'Integrations' },
  { value: 'other', label: 'Something else' },
];

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const fieldOrder = ['email', 'topic', 'message'];

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

const validate = (data: FormData, topic: string | null) => {
  const errors: Errors = {};
  const email = String(data.get('email')).trim();
  const message = String(data.get('message')).trim();

  if (email === '') errors.email = 'Enter an email so we can reply.';
  if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Use the full address, like ada@example.com.';
  if (topic === null) errors.topic = 'Choose a topic so the right team picks it up.';
  if (message.length < 20) errors.message = 'Describe the problem in at least 20 characters.';

  return errors;
};

const countArticles = (count: number) => `${count} ${count === 1 ? 'article' : 'articles'}`;

export default function HelpCenter() {
  const id = useId();
  const topicRef = useRef<HTMLButtonElement>(null);
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState('');
  const [topic, setTopic] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  const [errors, setErrors] = useState<Errors>({});
  const [sent, setSent] = useState('');

  const search = query.trim().toLowerCase();
  const results = categories
    .filter((item) => category === '' || item.value === category)
    .map((item) => ({
      ...item,
      articles: item.articles.filter((article) => `${article.question} ${article.answer}`.toLowerCase().includes(search)),
    }))
    .filter((item) => item.articles.length > 0);
  const total = results.reduce((sum, item) => sum + item.articles.length, 0);

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

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

  const changeTopic = (next: string | undefined) => {
    setTopic(next ?? null);
    clearError('topic');
  };

  const toggleCategory = (value: string) => {
    setCategory(category === value ? '' : value);
  };

  const clearSearch = () => {
    setQuery('');
    setCategory('');
  };

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

    setErrors(next);

    if (invalid === 'topic') {
      topicRef.current?.focus();
      return;
    }

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

    setSent(String(data.get('email')).trim());
  };

  const startOver = () => {
    setSent('');
    setTopic(null);
    setMessage('');
    setErrors({});
  };

  let status = `${countArticles(total)} in all topics`;
  if (category !== '') status = `${countArticles(total)} in ${categories.find((item) => item.value === category)?.title}`;
  if (search !== '') status = `${countArticles(total)} ${total === 1 ? 'matches' : 'match'} “${query.trim()}”`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <Heading as='h1' size={7} fontWeight={700}>How can we help?</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>Search the help center or browse a topic.</Text>
        </div>
        <div role='search' style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <TextField size='lg' style={{ paddingInlineStart: 'var(--spacing-8)' }}>
            <Icon aria-hidden='true' style={{ flexShrink: 0, color: 'var(--label-secondary)' }}><Search /></Icon>
            <TextFieldInput
              type='search'
              style={{ paddingInlineStart: 'var(--spacing-5)' }}
              aria-label='Search help articles'
              aria-describedby={`${id}-status`}
              placeholder='Try “invoice” or “webhooks”'
              value={query}
              onChange={(event) => setQuery(event.currentTarget.value)}
            />
          </TextField>
          <Text id={`${id}-status`} size={2} role='status' style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{status}</Text>
        </div>
      </header>

      <section aria-labelledby={`${id}-topics`} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <Heading id={`${id}-topics`} as='h2' size={4} fontWeight={600}>Popular topics</Heading>
        <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 220px), 1fr))', gap: 'var(--spacing-6)', margin: 0, padding: 0, listStyle: 'none' }}>
          {categories.map((item) => (
            <li key={item.value}>
              <button type='button' className='helpCenterTopic' aria-pressed={category === item.value} onClick={() => toggleCategory(item.value)}>
                <Icon aria-hidden='true'>{item.icon}</Icon>
                <Text size={3} fontWeight={500}>{item.title}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{countArticles(item.articles.length)}</Text>
              </button>
            </li>
          ))}
        </ul>
      </section>

      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 'var(--spacing-16)' }}>
        <section aria-labelledby={`${id}-faq`} style={{ display: 'grid', gap: 'var(--spacing-12)', flex: '999 1 420px', minWidth: 0 }}>
          <Heading id={`${id}-faq`} as='h2' size={4} fontWeight={600}>Frequently asked questions</Heading>

          {total === 0 && (
            <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start', padding: 'var(--spacing-12)', borderRadius: 'var(--radius-4)', background: 'var(--fills-quaternary)' }}>
              <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
                <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{`No articles match “${query.trim()}”`}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>Check the spelling, try a shorter phrase, or ask our support team below.</Text>
              </div>
              <Button size='xs' variant='secondary' appearance='ghost' onClick={clearSearch}>Clear search</Button>
            </div>
          )}

          {results.map((item) => (
            <div key={item.value} style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
              <Heading as='h3' size={3} fontWeight={600} style={{ color: 'var(--label-secondary)' }}>{item.title}</Heading>
              <Accordion>
                {item.articles.map((article, index) => (
                  <AccordionItem key={article.value} value={article.value}>
                    <AccordionHeader>
                      <AccordionTrigger>
                        {article.question}
                        <AccordionIcon />
                      </AccordionTrigger>
                    </AccordionHeader>
                    <AccordionContent>
                      <Text size={3} style={{ color: 'var(--label-secondary)' }}>{article.answer}</Text>
                    </AccordionContent>
                    {index < item.articles.length - 1 && <Separator />}
                  </AccordionItem>
                ))}
              </Accordion>
            </div>
          ))}
        </section>

        <aside style={{ display: 'grid', gap: 'var(--spacing-12)', flex: '1 1 320px', minWidth: 0 }}>
          <Card style={{ alignItems: 'stretch', gap: 'var(--spacing-8)' }} aria-labelledby={`${id}-status-title`}>
            <Collapsible>
              <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-4) var(--spacing-8)' }}>
                <div style={{ display: 'grid', gap: 'var(--spacing-2)', minWidth: 0 }}>
                  <Heading id={`${id}-status-title`} as='h2' size={4} fontWeight={600}>System status</Heading>
                  <span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                    <Badge size='sm' variant='warning'>Partial outage</Badge>
                    <Text size={2} style={{ color: 'var(--label-secondary)' }}>Updated 4 min ago</Text>
                  </span>
                </div>
                <CollapsibleTrigger asChild>
                  <Button size='xs' variant='secondary' appearance='ghost'>Details</Button>
                </CollapsibleTrigger>
              </div>
              <CollapsibleContent>
                <ul style={{ display: 'grid', gap: 'var(--spacing-6)', margin: 0, paddingBlockStart: 'var(--spacing-10)', paddingInline: 0, listStyle: 'none' }}>
                  {services.map((service) => (
                    <li key={service.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
                      <Text size={3}>{service.name}</Text>
                      <Badge size='sm' variant={service.variant} appearance='ghost'>{service.status}</Badge>
                    </li>
                  ))}
                </ul>
                <Text size={2} style={{ display: 'block', marginBlockStart: 'var(--spacing-8)', color: 'var(--label-secondary)' }}>
                  Webhook deliveries are delayed by up to 10 minutes. No events are lost.
                </Text>
              </CollapsibleContent>
            </Collapsible>
          </Card>

          <Card style={{ alignItems: 'stretch', gap: 'var(--spacing-10)' }} aria-labelledby={`${id}-contact`}>
            <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
              <Heading id={`${id}-contact`} as='h2' size={4} fontWeight={600}>Still need help?</Heading>
              <Text size={2} style={{ color: 'var(--label-secondary)' }}>Our team replies within one business day.</Text>
            </div>

            {sent !== '' && (
              <>
                <Alert variant='success'>
                  <AlertIcon />
                  <AlertMain>
                    <AlertTitle>Message sent</AlertTitle>
                    <AlertContent style={{ overflowWrap: 'anywhere' }}>{`We’ll reply to ${sent}. Your ticket number is #48213.`}</AlertContent>
                  </AlertMain>
                </Alert>
                <Button size='xs' variant='secondary' appearance='ghost' style={{ justifySelf: 'start' }} onClick={startOver}>Send another message</Button>
              </>
            )}

            {sent === '' && (
              <form noValidate aria-labelledby={`${id}-contact`} onSubmit={submit} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
                <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}-topic`}>Topic</Label>
                  <Select value={topic} onChangeValue={changeTopic}>
                    <SelectHiddenSelect name='topic' />
                    <SelectCombobox
                      id={`${id}-topic`}
                      ref={topicRef}
                      aria-invalid={errors.topic !== undefined}
                      aria-describedby={describe('topic')}
                    >
                      <SelectValue placeholder='Choose a topic' />
                      <SelectIcon />
                    </SelectCombobox>
                    <SelectListbox>
                      {topics.map((item) => (
                        <SelectOption key={item.value} value={item.value}>{item.label}</SelectOption>
                      ))}
                    </SelectListbox>
                  </Select>
                  {errors.topic && <HelperText id={`${id}-topic-error`} error>{errors.topic}</HelperText>}
                </div>
                <div style={field}>
                  <Label htmlFor={`${id}-message`}>Message</Label>
                  <TextArea
                    id={`${id}-message`}
                    name='message'
                    rows={5}
                    maxLength={MESSAGE_LIMIT}
                    fullWidth
                    error={errors.message !== undefined}
                    value={message}
                    placeholder='What were you trying to do, and what happened instead?'
                    aria-invalid={errors.message !== undefined}
                    aria-describedby={errors.message ? `${id}-message-error ${id}-message-count` : `${id}-message-count ${id}-message-hint`}
                    onChange={(event) => {
                      setMessage(event.currentTarget.value);
                      clearError('message');
                    }}
                  />
                  {errors.message && <HelperText id={`${id}-message-error`} error>{errors.message}</HelperText>}
                  <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
                    <HelperText id={`${id}-message-hint`}>Screenshots help. Reply to our email to attach them.</HelperText>
                    <HelperText id={`${id}-message-count`} style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>{`${message.length} / ${MESSAGE_LIMIT}`}</HelperText>
                  </div>
                </div>
                <Button size='xs' type='submit' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-4)' }}>Send message</Button>
              </form>
            )}
          </Card>
        </aside>
      </div>
    </div>
  );
}

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