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

Входящие

Двухпанельные входящие поддержки: фильтры, список диалогов с клавиатуры, назначение и ответы.

Код

docs/examples/templates/inbox.tsx
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import {
  Avatar,
  AvatarFallback,
  Badge,
  Button,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Listbox,
  ListboxOption,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Text,
  TextArea,
} from '@vega-ui/react';
import { ChevronLeft } from '@vega-ui/icons';
import './inbox.css';

type Filter = 'all' | 'unread' | 'assigned';

interface Message {
  author: string
  time: string
  body: string
  agent: boolean
}

interface Conversation {
  id: string
  name: string
  email: string
  subject: string
  time: string
  unread: boolean
  resolved: boolean
  assignee: string
  messages: Message[]
}

const ME = 'elena';

const agents = [
  { value: 'none', label: 'Unassigned' },
  { value: 'elena', label: 'Elena Moreau (you)' },
  { value: 'omar', label: 'Omar Haddad' },
  { value: 'lena', label: 'Lena Fischer' },
];

const filters: { value: Filter, label: string }[] = [
  { value: 'all', label: 'All' },
  { value: 'unread', label: 'Unread' },
  { value: 'assigned', label: 'Assigned to me' },
];

const initialConversations: Conversation[] = [
  {
    id: 'c1',
    name: 'Maya Chen',
    email: 'maya.chen@hey.com',
    subject: 'Charged twice for November',
    time: '9:42 AM',
    unread: true,
    resolved: false,
    assignee: 'none',
    messages: [
      { author: 'Maya Chen', time: '9:42 AM', agent: false, body: 'Hi! My card was charged $128 twice on November 1. Could you refund one of the charges? The invoice number is INV-2026-011.' },
    ],
  },
  {
    id: 'c2',
    name: 'Omar Haddad',
    email: 'omar@haddad.studio',
    subject: 'SSO setup with Okta',
    time: '8:15 AM',
    unread: false,
    resolved: false,
    assignee: 'elena',
    messages: [
      { author: 'Omar Haddad', time: 'Yesterday', agent: false, body: 'We are moving the studio to Okta. Is SAML available on the Business plan, and do we need to re-invite everyone?' },
      { author: 'Elena Moreau', time: 'Yesterday', agent: true, body: 'Yes, SAML is part of Business. Existing members keep their accounts; they sign in with Okta from the next session.' },
      { author: 'Omar Haddad', time: '8:15 AM', agent: false, body: 'Great. Where do I find the ACS URL and the entity ID?' },
    ],
  },
  {
    id: 'c3',
    name: 'Lena Fischer',
    email: 'lena.fischer@posteo.de',
    subject: 'Export comments to CSV',
    time: 'Yesterday',
    unread: false,
    resolved: false,
    assignee: 'lena',
    messages: [
      { author: 'Lena Fischer', time: 'Yesterday', agent: false, body: 'Is there a way to export all review comments from a project to CSV? We need them for an audit.' },
    ],
  },
  {
    id: 'c4',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@gmail.com',
    subject: 'Can’t upload files over 2 GB',
    time: 'Mon',
    unread: false,
    resolved: false,
    assignee: 'elena',
    messages: [
      { author: 'Tomás Ruiz', time: 'Mon', agent: false, body: 'Uploads stop at 99% for anything bigger than 2 GB. Smaller files work fine. I’m on Chrome 131 on macOS.' },
      { author: 'Elena Moreau', time: 'Mon', agent: true, body: 'Thanks for the details. We’re looking into it and will update you here.' },
    ],
  },
  {
    id: 'c5',
    name: 'Aiko Tanaka',
    email: 'aiko@tanaka.jp',
    subject: 'Thanks for the quick fix',
    time: 'Oct 28',
    unread: false,
    resolved: true,
    assignee: 'omar',
    messages: [
      { author: 'Aiko Tanaka', time: 'Oct 28', agent: false, body: 'Just wanted to say the dashboard loads instantly now. Thank you!' },
    ],
  },
];

const getInitials = (name: string) => name.split(' ').map((part) => part[0]).join('').slice(0, 2);

const ellipsis = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 } as const;

export default function Inbox() {
  const id = useId();
  const listboxRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLHeadingElement>(null);
  const backRef = useRef<HTMLButtonElement>(null);
  const focusAfterView = useRef(false);
  const [conversations, setConversations] = useState(initialConversations);
  const [filter, setFilter] = useState<Filter>('all');
  const [selected, setSelected] = useState('c2');
  const [view, setView] = useState<'list' | 'detail'>('list');
  const [reply, setReply] = useState('');
  const [replyError, setReplyError] = useState('');
  const [notice, setNotice] = useState('');

  const visible = conversations.filter((conversation) => {
    if (filter === 'unread') return conversation.unread;
    if (filter === 'assigned') return conversation.assignee === ME;

    return true;
  });

  const conversation = conversations.find((item) => item.id === selected) ?? conversations[0];
  const unreadCount = conversations.filter((item) => item.unread).length;

  useEffect(() => {
    if (!focusAfterView.current) return;

    focusAfterView.current = false;

    if (view === 'list') {
      listboxRef.current?.focus();
      return;
    }

    if (backRef.current?.checkVisibility()) titleRef.current?.focus();
  }, [view]);

  const update = (changes: Partial<Conversation>) => {
    setConversations((current) => current.map((item) => (item.id === conversation.id ? { ...item, ...changes } : item)));
  };

  const open = (next: string | null | undefined) => {
    if (next === null || next === undefined) return;

    setConversations((current) => current.map((item) => (item.id === next ? { ...item, unread: false } : item)));
    setSelected(next);
    setReply('');
    setReplyError('');
    setNotice('');
    focusAfterView.current = true;
    setView('detail');
  };

  const back = () => {
    focusAfterView.current = true;
    setView('list');
  };

  const assign = (assignee: string | undefined) => {
    if (assignee === undefined) return;

    update({ assignee });
    setNotice(assignee === 'none' ? 'Conversation unassigned.' : `Assigned to ${agents.find((agent) => agent.value === assignee)?.label}.`);
  };

  const toggleResolved = () => {
    update({ resolved: !conversation.resolved });
    setNotice(conversation.resolved ? 'Conversation reopened.' : 'Marked as resolved.');
  };

  const send = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;

    if (reply.trim() === '') {
      setReplyError('Write a reply before sending.');
      const element = form.elements.namedItem('reply');
      if (element instanceof HTMLElement) element.focus();
      return;
    }

    update({
      resolved: false,
      messages: [...conversation.messages, { author: 'Elena Moreau', time: 'Just now', agent: true, body: reply.trim() }],
    });
    setReply('');
    setNotice(`Reply sent to ${conversation.name}.`);
  };

  return (
    <div className='inbox' style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
        <Heading as='h1' size={7} fontWeight={700}>Inbox</Heading>
        <Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
          {unreadCount === 0 ? 'You’re all caught up.' : `${unreadCount} unread conversation${unreadCount === 1 ? '' : 's'} in Support.`}
        </Text>
      </header>

      <div className='inboxPanes' data-view={view}>
        <section className='inboxList' aria-labelledby={`${id}-list`}>
          <Heading id={`${id}-list`} as='h2' size={4} fontWeight={600}>Conversations</Heading>
          <SegmentedControl
            size='sm'
            name={`${id}-filter`}
            role='radiogroup'
            aria-label='Show'
            value={filter}
            onChange={(event) => setFilter(event.target.value as Filter)}
            style={{ alignSelf: 'stretch' }}
          >
            {filters.map((item) => (
              <SegmentedControlItem key={item.value} value={item.value}>
                <SegmentedControlItemInput />
                {item.label}
              </SegmentedControlItem>
            ))}
            <SegmentedControlIndicator />
          </SegmentedControl>
          {visible.length === 0 && (
            <div style={{ display: 'grid', gap: 'var(--spacing-2)', justifyItems: 'center', paddingBlock: 'var(--spacing-20)', textAlign: 'center' }}>
              <Text size={3} fontWeight={600}>Nothing here</Text>
              <Text size={2} style={{ color: 'var(--label-secondary)' }}>{filter === 'unread' ? 'You’ve read every conversation.' : 'No conversations are assigned to you.'}</Text>
            </div>
          )}
          {visible.length > 0 && (
            <Listbox ref={listboxRef} aria-labelledby={`${id}-list`} value={selected} onChangeValue={open} className='inboxListbox'>
              {visible.map((item) => (
                <ListboxOption key={item.id} value={item.id} label={item.name} className='inboxOption'>
                  <Avatar size='md' variant={item.unread ? 'primary' : 'secondary'} aria-hidden='true' style={{ flexShrink: 0 }}>
                    <AvatarFallback>{getInitials(item.name)}</AvatarFallback>
                  </Avatar>
                  <span style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 'var(--spacing-1)', flex: 1, minWidth: 0 }}>
                    <span style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--spacing-4)' }}>
                      <Text size={3} fontWeight={item.unread ? 700 : 500} style={ellipsis}>{item.name}</Text>
                      <Text size={1} style={{ marginInlineStart: 'auto', flexShrink: 0, color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{item.time}</Text>
                    </span>
                    <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                      <Text size={2} fontWeight={item.unread ? 600 : 400} style={ellipsis}>{item.subject}</Text>
                      {item.unread && <Badge size='xs' variant='info' style={{ marginInlineStart: 'auto' }}>New</Badge>}
                      {item.resolved && <Badge size='xs' variant='success' style={{ marginInlineStart: 'auto' }}>Resolved</Badge>}
                    </span>
                    <Text size={2} style={{ ...ellipsis, color: 'var(--label-secondary)' }}>{item.messages[item.messages.length - 1].body}</Text>
                  </span>
                </ListboxOption>
              ))}
            </Listbox>
          )}
        </section>

        <section className='inboxDetail' aria-labelledby={`${id}-subject`}>
          <div style={{ display: 'grid', gap: 'var(--spacing-8)', paddingBlockEnd: 'var(--spacing-8)', borderBlockEnd: '1px solid var(--separator-opaque)' }}>
            <div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-4)' }}>
              <IconButton
                ref={backRef}
                size='xs'
                variant='secondary'
                appearance='ghost'
                aria-label='Back to conversations'
                className='inboxBack'
                onClick={back}
              >
                <Icon size='sm'><ChevronLeft /></Icon>
              </IconButton>
              <div style={{ display: 'grid', gap: 'var(--spacing-1)', flex: 1, minWidth: 0 }}>
                <Heading ref={titleRef} id={`${id}-subject`} as='h2' size={5} fontWeight={600} tabIndex={-1} style={{ outline: 'none', overflowWrap: 'anywhere' }}>
                  {conversation.subject}
                </Heading>
                <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{`${conversation.name} · ${conversation.email}`}</Text>
              </div>
              <Badge size='sm' variant={conversation.resolved ? 'success' : 'warning'} style={{ marginBlockStart: 'var(--spacing-2)' }}>
                {conversation.resolved ? 'Resolved' : 'Open'}
              </Badge>
            </div>
            <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4) var(--spacing-6)' }}>
              <Label htmlFor={`${id}-assignee`}>Assignee</Label>
              <Select size='xs' value={conversation.assignee} onChangeValue={assign} style={{ flex: '1 1 180px', maxWidth: 240 }}>
                <SelectCombobox id={`${id}-assignee`}>
                  <SelectValue />
                  <SelectIcon />
                </SelectCombobox>
                <SelectListbox>
                  {agents.map((agent) => <SelectOption key={agent.value} value={agent.value}>{agent.label}</SelectOption>)}
                </SelectListbox>
              </Select>
              <Button size='xs' variant='secondary' appearance='ghost' onClick={toggleResolved} style={{ marginInlineStart: 'auto' }}>
                {conversation.resolved ? 'Reopen' : 'Mark as resolved'}
              </Button>
            </div>
          </div>

          <ol aria-label='Messages' style={{ display: 'grid', alignContent: 'start', gap: 'var(--spacing-10)', flex: 1, margin: 0, padding: 0, listStyle: 'none' }}>
            {conversation.messages.map((message, index) => (
              <li key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-6)' }}>
                <Avatar size='sm' variant={message.agent ? 'primary' : 'secondary'} aria-hidden='true' style={{ flexShrink: 0 }}>
                  <AvatarFallback>{getInitials(message.author)}</AvatarFallback>
                </Avatar>
                <div style={{ display: 'grid', gap: 'var(--spacing-2)', minWidth: 0 }}>
                  <span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 'var(--spacing-1) var(--spacing-4)' }}>
                    <Text size={2} fontWeight={600}>{message.author}</Text>
                    <Text size={1} style={{ color: 'var(--label-secondary)' }}>{message.time}</Text>
                  </span>
                  <Text
                    size={3}
                    style={{
                      display: 'block',
                      padding: 'var(--spacing-5) var(--spacing-7)',
                      borderRadius: 'var(--radius-4)',
                      background: message.agent ? 'color-mix(in oklch, var(--color-primary-500) 12%, transparent)' : 'var(--fills-quaternary)',
                      overflowWrap: 'anywhere',
                    }}
                  >
                    {message.body}
                  </Text>
                </div>
              </li>
            ))}
          </ol>

          <form noValidate onSubmit={send} style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
            <Label htmlFor={`${id}-reply`}>{`Reply to ${conversation.name}`}</Label>
            <TextArea
              id={`${id}-reply`}
              name='reply'
              rows={3}
              fullWidth
              value={reply}
              error={replyError !== ''}
              placeholder='Write a reply…'
              aria-invalid={replyError !== ''}
              aria-describedby={replyError === '' ? undefined : `${id}-reply-error`}
              onChange={(event) => {
                setReply(event.currentTarget.value);
                setReplyError('');
              }}
            />
            {replyError && <HelperText id={`${id}-reply-error`} error>{replyError}</HelperText>}
            <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-3)' }}>
              <Text size={2} role='status' style={{ marginInlineEnd: 'auto', color: 'var(--label-secondary)' }}>{notice}</Text>
              <Button size='xs' type='submit'>Send reply</Button>
            </div>
          </form>
        </section>
      </div>
    </div>
  );
}

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