diff --git a/packages/react-components/src/components/Avatar/Avatar.module.scss b/packages/react-components/src/components/Avatar/Avatar.module.scss index d8eda5a27..06acf33cc 100644 --- a/packages/react-components/src/components/Avatar/Avatar.module.scss +++ b/packages/react-components/src/components/Avatar/Avatar.module.scss @@ -27,11 +27,11 @@ $base-class: 'avatar'; } &--unavailable { - background: var(--action-negative-default); + background: var(--surface-accent-emphasis-high-negative); } &--unknown { - background: var(--surface-moderate-default); + background: var(--action-neutral-default); } &--xxxsmall#{$circle-class}, diff --git a/packages/react-components/src/components/Avatar/Avatar.tsx b/packages/react-components/src/components/Avatar/Avatar.tsx index f7b6d634e..278141d81 100644 --- a/packages/react-components/src/components/Avatar/Avatar.tsx +++ b/packages/react-components/src/components/Avatar/Avatar.tsx @@ -68,6 +68,7 @@ export const Avatar: React.FC = ({ text, type, withRim = false, + style, ...props }) => { const isImproperImageSetup = type === 'image' && !src; @@ -136,7 +137,11 @@ export const Avatar: React.FC = ({ }, [isImproperImageSetup]); return ( -
+
{withRim && (
+ +InviteAgents + +## Intro + +The `InviteAgents` component is used to display a list of invited agents and provide functionality to add more agents or set up a chatbot. + + + +### Example implementation + +```jsx +import { InviteAgents } from '@livechat/design-system-react-components'; + +const agents = [ + { + name: 'Alice', + email: 'alice@example.com', + status: 'available', + avatar: 'https://via.placeholder.com/150', + }, +]; + +const onAddTeammateClick = () => {}; +const onSetUpChatbotClick = () => {}; + +return ( + +); + +``` + +## Component API + + \ No newline at end of file diff --git a/packages/react-components/src/components/InviteAgents/InviteAgents.module.scss b/packages/react-components/src/components/InviteAgents/InviteAgents.module.scss new file mode 100644 index 000000000..69155a1d4 --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/InviteAgents.module.scss @@ -0,0 +1,125 @@ +$base-class: 'invite-agents'; + +.#{$base-class} { + display: flex; + align-items: center; + border: 1px solid transparent; + border-radius: var(--radius-4); + background-color: var(--surface-secondary-default); + max-width: 260px; + height: 32px; + + &:hover { + border-color: var(--border-basic-secondary); + } + + &--empty { + border: 0; + } + + &--only-unavailable { + border-color: var(--action-negative-default); + background-color: var(--surface-accent-ondark-negative-default); + + &:hover { + border-color: var(--border-basic-negative); + } + } + + &__not-accepting { + display: flex; + gap: var(--spacing-2); + align-items: center; + margin-right: var(--spacing-2); + margin-left: var(--spacing-2); + cursor: pointer; + color: var(--content-basic-primary); + } + + &__tooltip { + max-width: 260px; + + > div { + display: flex; + flex-direction: column; + + > p { + display: inline-flex; + gap: var(--spacing-3); + align-items: center; + } + } + } + + &__tooltip-trigger { + display: flex; + gap: var(--spacing-2); + align-items: center; + cursor: pointer; + padding-right: var(--spacing-2); + padding-left: var(--spacing-1); + } + + &__not-accepting-status-dot { + display: inline-block; + border: 1px solid var(--navbar-background); + border-radius: 50%; + background: var(--surface-accent-emphasis-high-negative); + width: 10px; + height: 10px; + } + + &__avatar-container { + display: flex; + } + + &__avatar { + position: relative; + border: 1px solid var(--border-basic-tertiary); + + &:not(:first-child) { + margin-left: -10px; + } + + > div { + border: 1px solid var(--border-basic-tertiary); + } + } + + &__available-agents-number { + text-transform: uppercase; + line-height: 20px; + letter-spacing: 0.2px; + color: var(--content-basic-secondary); + font-size: 12px; + font-weight: 500; + font-style: normal; + } + + &__invite-button { + margin-right: -1px; + border: 1px solid var(--content-basic-disabled); + border-radius: 24px; + background-color: var(--surface-primary-default); + padding: 6px 12px; + min-width: 0; + height: 32px; + color: var(--content-basic-primary); + + &--animated { + margin-right: 4px; + height: 24px; + + &[class*='--animated-label'][class*='--with-left-icon'] { + padding-right: 3px; + padding-left: 3px; + + &:hover, + &:focus-visible { + margin-right: -1px; + height: 32px; + } + } + } + } +} diff --git a/packages/react-components/src/components/InviteAgents/InviteAgents.spec.tsx b/packages/react-components/src/components/InviteAgents/InviteAgents.spec.tsx new file mode 100644 index 000000000..96604f94f --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/InviteAgents.spec.tsx @@ -0,0 +1,180 @@ +import userEvent from '@testing-library/user-event'; + +import { render, screen, vi, waitFor } from 'test-utils'; + +import { InviteAgents } from './InviteAgents'; +import { Agent } from './types'; + +describe('InviteAgents Component', () => { + const mockOnAddAgentsClick = vi.fn(); + const mockOnSetUpChatbotClick = vi.fn(); + + const renderComponent = ( + agents: Agent[] = [], + animatedInviteButton = false + ) => { + render( + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with no agents', () => { + renderComponent([]); + + const inviteButton = screen.getByRole('button', { name: 'Invite' }); + expect(inviteButton).toBeInTheDocument(); + + const tooltipHeading = screen.queryByText(/team status/i); + expect(tooltipHeading).not.toBeInTheDocument(); + }); + + it('renders only available agents', async () => { + const agents: Agent[] = [ + { + name: 'Alice', + email: 'alice@example.com', + status: 'available', + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Bob', + email: 'bob@example.com', + status: 'unavailable', + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Charlie', + email: 'charlie@example.com', + status: 'unknown', + avatar: 'https://via.placeholder.com/150', + }, + ]; + + renderComponent(agents); + + const avatars = screen.getAllByRole('img'); + expect(avatars).toHaveLength(1); + + userEvent.hover(avatars[0]); + + await waitFor(() => { + expect(screen.getByText(/1 agent accepting chats/i)).toBeInTheDocument(); + }); + }); + + it('applies correct classes when there are only unavailable agents', () => { + const agents: Agent[] = [ + { + name: 'Bob', + email: 'bob@example.com', + status: 'unavailable', + avatar: 'https://via.placeholder.com/150', + }, + ]; + + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass(/invite-agents--only-unavailable/); + }); + + it('shows animated invite button when animatedInviteButton is true and agents are present', () => { + const agents: Agent[] = [ + { + name: 'Alice', + email: 'alice@example.com', + status: 'available', + avatar: 'https://via.placeholder.com/150', + }, + ]; + + renderComponent(agents, true); + + const inviteButton = screen.getByRole('button', { name: 'Invite' }); + expect(inviteButton).toHaveClass(/invite-agents__invite-button--animated/); + }); + + it('shows action menu when invite button is clicked', async () => { + renderComponent(); + + const inviteButton = screen.getByRole('button', { name: 'Invite' }); + userEvent.click(inviteButton); + + await waitFor(() => { + expect(screen.getByText(/invite teammate/i)).toBeInTheDocument(); + expect(screen.getByText(/set up chatbot/i)).toBeInTheDocument(); + }); + + userEvent.click(screen.getByText(/invite teammate/i)); + + expect(mockOnAddAgentsClick).toHaveBeenCalledTimes(1); + }); + + it('displays additional agents count correctly', () => { + const agents: Agent[] = Array.from({ length: 5 }, (_, index) => ({ + name: `Agent ${index + 1}`, + email: `agent${index + 1}@example.com`, + status: 'available', + avatar: 'https://via.placeholder.com/150', + })); + + renderComponent(agents); + + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('renders "Not accepting" when all agents are unavailable', async () => { + const agents: Agent[] = [ + { + name: 'Bob', + email: 'bob@example.com', + status: 'unavailable', + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Charlie', + email: 'charlie@example.com', + status: 'unavailable', + avatar: 'https://via.placeholder.com/150', + }, + ]; + + renderComponent(agents); + + const notAcceptingText = screen.getByText(/no active agents/i); + expect(notAcceptingText).toBeInTheDocument(); + + userEvent.hover(notAcceptingText); + + await waitFor(() => { + expect( + screen.getByText("No one's available to assist customers") + ).toBeInTheDocument(); + expect( + screen.getByText('Offer 24/7 support with ChatBot.') + ).toBeInTheDocument(); + }); + + const chatbotButton = screen.getByRole('button', { + name: /set up chatbot/i, + }); + expect(chatbotButton).toBeInTheDocument(); + + userEvent.click(chatbotButton); + expect(mockOnSetUpChatbotClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/src/components/InviteAgents/InviteAgents.stories.tsx b/packages/react-components/src/components/InviteAgents/InviteAgents.stories.tsx new file mode 100644 index 000000000..9b68825a6 --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/InviteAgents.stories.tsx @@ -0,0 +1,88 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; + +import { InviteAgents } from './InviteAgents'; + +const meta: Meta = { + title: 'Business Components/InviteAgents', + component: InviteAgents, + render: (args) => ( +
+ +
+ ), +}; + +export default meta; +type Story = StoryObj; + +const mockAgents = [ + { + name: 'Bob Smith', + email: 'bob@example.com', + status: 'unknown' as const, + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Alice Johnson', + email: 'alice@example.com', + status: 'available' as const, + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Alice Johnson 2', + email: 'alic2@example.com', + status: 'available' as const, + avatar: 'https://via.placeholder.com/150', + }, + { + name: 'Bob Smith', + email: 'bob3@example.com', + status: 'unavailable' as const, + avatar: 'https://via.placeholder.com/150', + }, + ...[...Array(10)].map((_, index) => ({ + name: `Unknown Agent ${index}`, + email: `unknown${index}@example.com`, + status: 'available' as const, + avatar: 'https://via.placeholder.com/150', + })), +]; + +export const Default: Story = { + args: { + agents: mockAgents, + onAddTeammateClick: action('Add Teammate Clicked'), + onSetUpChatbotClick: action('Set Up Chatbot Clicked'), + }, +}; + +export const AnimatedInviteButton: Story = { + args: { + agents: mockAgents, + onAddTeammateClick: action('Add Teammate Clicked'), + onSetUpChatbotClick: action('Set Up Chatbot Clicked'), + animatedInviteButton: true, + }, +}; + +export const OnlyUnavailableAgents: Story = { + args: { + agents: mockAgents.map((agent) => ({ + ...agent, + status: 'unavailable' as const, + })), + onAddTeammateClick: action('Add Teammate Clicked'), + onSetUpChatbotClick: action('Set Up Chatbot Clicked'), + }, +}; + +export const NoAgents: Story = { + args: { + agents: [], + onAddTeammateClick: action('Add Teammate Clicked'), + onSetUpChatbotClick: action('Set Up Chatbot Clicked'), + }, +}; diff --git a/packages/react-components/src/components/InviteAgents/InviteAgents.tsx b/packages/react-components/src/components/InviteAgents/InviteAgents.tsx new file mode 100644 index 000000000..0dfb5a3be --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/InviteAgents.tsx @@ -0,0 +1,179 @@ +import { FC, memo, useMemo, useState } from 'react'; + +import { Add, ChatBotColored, PersonAdd } from '@livechat/design-system-icons'; +import cx from 'clsx'; + +import { ThemeClassName } from '../../providers'; +import { ActionMenu, ActionMenuItem } from '../ActionMenu'; +import { Avatar } from '../Avatar'; +import { Button } from '../Button'; +import { Icon } from '../Icon'; +import { Interactive, Tooltip } from '../Tooltip'; +import { Text } from '../Typography'; + +import { getAvailableAgentsTooltipText, getSortedAgents } from './helpers'; +import { InviteAgentsProps } from './types'; + +import styles from './InviteAgents.module.scss'; + +const baseClass = 'invite-agents'; + +const InviteAgentsComponent: FC = ({ + agents, + onSetUpChatbotClick, + onAddTeammateClick, + className, + animatedInviteButton = false, + tooltipArrowOffset = 13, + ...props +}) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const shouldAnimateInviteButton = + animatedInviteButton && agents.length > 0 && !isMenuOpen; + const { + availableAgentsNumber, + visibleAgents, + additionalAgentsNumber, + hasOnlyUnavailableAgents, + } = useMemo(() => { + const availableAgents = agents.filter( + (agent) => agent.status === 'available' + ); + const sortedAgents = getSortedAgents(availableAgents); + const availableAgentsNumber = availableAgents.length; + + const visibleAgents = + sortedAgents.length > 4 ? sortedAgents.slice(0, 3) : sortedAgents; + const additionalAgentsNumber = availableAgentsNumber - visibleAgents.length; + const hasOnlyUnavailableAgents = + agents.length > 0 && availableAgentsNumber === 0; + + return { + availableAgentsNumber, + visibleAgents, + additionalAgentsNumber, + hasOnlyUnavailableAgents, + }; + }, [agents]); + + const menuOptions = [ + { + key: 'chatbot', + onClick: onSetUpChatbotClick, + element: ( + }> + Set up ChatBot + + ), + }, + { + key: 'teammate', + element: ( + }> + Invite teammate + + ), + onClick: onAddTeammateClick, + }, + ]; + + return ( +
+ {agents.length > 0 && ( + +
+ + No active agents + +
+ ) : ( +
+
+ {visibleAgents.map((agent, index) => ( + + ))} +
+ {additionalAgentsNumber > 0 && ( + + +{additionalAgentsNumber} + + )} +
+ ) + } + > + {hasOnlyUnavailableAgents ? ( + + ) : ( + + {getAvailableAgentsTooltipText(availableAgentsNumber)} + + )} +
+ )} + + setIsMenuOpen(true)} + onClose={() => setIsMenuOpen(false)} + options={menuOptions} + triggerRenderer={ + + } + /> +
+ ); +}; + +export const InviteAgents = memo(InviteAgentsComponent); diff --git a/packages/react-components/src/components/InviteAgents/helpers.ts b/packages/react-components/src/components/InviteAgents/helpers.ts new file mode 100644 index 000000000..69a13fd01 --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/helpers.ts @@ -0,0 +1,26 @@ +import { Agent } from './types'; + +const statusPriority: { [key: string]: number } = { + available: 1, + unavailable: 2, + unknown: 3, +}; + +export const getSortedAgents = (agents: Agent[]) => { + return [...agents].sort((a, b) => { + return statusPriority[a.status] - statusPriority[b.status]; + }); +}; + +export const getAvailableAgentsTooltipText = ( + availableAgentsNumber: number +) => { + if (availableAgentsNumber === 0) { + return 'No one assist your customers'; + } + if (availableAgentsNumber === 1) { + return '1 agent accepting chats'; + } + + return `${availableAgentsNumber} agents accepting chats`; +}; diff --git a/packages/react-components/src/components/InviteAgents/index.ts b/packages/react-components/src/components/InviteAgents/index.ts new file mode 100644 index 000000000..457fb4e0b --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/index.ts @@ -0,0 +1,2 @@ +export { InviteAgents } from './InviteAgents'; +export { type InviteAgentsProps, type Agent } from './types'; diff --git a/packages/react-components/src/components/InviteAgents/types.ts b/packages/react-components/src/components/InviteAgents/types.ts new file mode 100644 index 000000000..e36c67ba4 --- /dev/null +++ b/packages/react-components/src/components/InviteAgents/types.ts @@ -0,0 +1,31 @@ +import { ComponentCoreProps } from '../../utils/types'; + +export interface Agent { + name: string; + email: string; + status: 'available' | 'unavailable' | 'unknown'; + avatar: string; +} + +export interface InviteAgentsProps extends ComponentCoreProps { + /** + * The list of invited agents + */ + agents: Agent[]; + /** + * The function to call when the "Set up Chatbot" button is clicked + */ + onSetUpChatbotClick: () => void; + /** + * The function to call when the "Invite Teammate" button is clicked + */ + onAddTeammateClick: () => void; + /** + * Whether the invite button should be animated + */ + animatedInviteButton?: boolean; + /** + * Offset for the tooltip arrow + */ + tooltipArrowOffset?: number; +} diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index a467a83be..1cc615740 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -28,6 +28,7 @@ export * from './components/FormField'; export * from './components/FormGroup'; export * from './components/Icon'; export * from './components/Input'; +export * from './components/InviteAgents'; export * from './components/Link'; export * from './components/Loader'; export * from './components/Modal'; diff --git a/packages/react-components/src/utils/types.ts b/packages/react-components/src/utils/types.ts index b566e3fea..8f9781175 100644 --- a/packages/react-components/src/utils/types.ts +++ b/packages/react-components/src/utils/types.ts @@ -1,6 +1,12 @@ +import * as React from 'react'; + export type Size = 'compact' | 'medium' | 'large'; export interface ComponentCoreProps { + /** + * Custom style for the avatar + */ + style?: React.CSSProperties; /** * The CSS class name */