This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
'use client';
import { FormEvent, useEffect, useRef } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import type { Attachment, Conversation } from '../utils/types';
import { ChatHeader } from './chat-header';
import { MessageBubble } from './message-bubble';
import { MessageComposer } from './message-composer';
interface ChatAreaProps {
conversation: Conversation;
draft: string;
onDraftChange: (text: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
attachments: Attachment[];
onAddAttachments: (files: FileList) => void;
onRemoveAttachment: (id: string) => void;
}
export function ChatArea({
conversation,
draft,
onDraftChange,
onSubmit,
attachments,
onAddAttachments,
onRemoveAttachment
}: ChatAreaProps) {
const shouldReduceMotion = useReducedMotion();
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const liveRegionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!messagesContainerRef.current) return;
const container = messagesContainerRef.current;
const behavior = shouldReduceMotion ? 'auto' : 'smooth';
const scrollToBottom = () => {
container.scrollTo({ top: container.scrollHeight, behavior });
};
if (behavior === 'smooth') {
requestAnimationFrame(scrollToBottom);
} else {
scrollToBottom();
}
}, [conversation.messages, conversation.id, shouldReduceMotion]);
useEffect(() => {
if (!liveRegionRef.current) return;
const lastMessage = conversation.messages[conversation.messages.length - 1];
if (!lastMessage) return;
liveRegionRef.current.textContent =
lastMessage.author + ' at ' + lastMessage.timestamp + ': ' + lastMessage.text;
}, [conversation.messages]);
return (
<>
<AnimatePresence initial={false} mode='wait'>
<motion.div
key={conversation.id}
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }}
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
transition={{ duration: 0.32, ease: 'easeOut' }}
className='border-border/40 bg-background/80 flex min-h-0 flex-col gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:p-4 lg:col-start-2 lg:col-end-3 lg:rounded-3xl'
>
<ChatHeader conversation={conversation} />
<div
ref={messagesContainerRef}
className='[&::-webkit-scrollbar-thumb]:bg-muted relative min-h-0 flex-1 space-y-3 overflow-y-auto pr-2 sm:space-y-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full'
aria-live='off'
aria-label={'Message thread with ' + conversation.name}
>
<AnimatePresence initial={false}>
{conversation.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
</div>
<MessageComposer
draft={draft}
onDraftChange={onDraftChange}
onSubmit={onSubmit}
contactName={conversation.name}
quickReplies={conversation.quickReplies}
attachments={attachments}
onAddAttachments={onAddAttachments}
onRemoveAttachment={onRemoveAttachment}
/>
</motion.div>
</AnimatePresence>
<div ref={liveRegionRef} className='sr-only' aria-live='polite' aria-atomic='true' />
</>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Icons } from '@/components/icons';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Conversation } from '../utils/types';
const statusDotColor = {
online: 'bg-green-500',
offline: 'bg-red-500'
} as const;
interface ChatHeaderProps {
conversation: Conversation;
}
export function ChatHeader({ conversation }: ChatHeaderProps) {
return (
<header className='flex flex-wrap items-center justify-between gap-3 sm:gap-4'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='relative'>
<Avatar className='border-border/40 bg-card/80 text-foreground h-10 w-10 rounded-2xl border sm:h-12 sm:w-12 sm:rounded-3xl'>
<AvatarFallback className='bg-primary/20 text-primary rounded-2xl text-sm font-semibold sm:rounded-3xl sm:text-base'>
{conversation.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2 sm:h-3.5 sm:w-3.5',
statusDotColor[conversation.status]
)}
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
/>
</div>
<div>
<p className='text-foreground text-sm font-semibold sm:text-base'>{conversation.name}</p>
<p className='text-muted-foreground text-xs sm:text-sm'>{conversation.title}</p>
</div>
</div>
<div className='flex items-center gap-1.5 sm:gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Start audio call'
>
<Icons.phone className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Start video call'
>
<Icons.video className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Open conversation menu'
>
<Icons.ellipsis className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,11 @@
'use client';
import { Messenger } from './messenger';
export default function ChatViewPage() {
return (
<div className='flex min-h-0 flex-1 px-4 py-2 md:px-6'>
<Messenger />
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useMemo, useState } from 'react';
import { Icons } from '@/components/icons';
import { motion } from 'motion/react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { Conversation } from '../utils/types';
const statusDotColor = {
online: 'bg-green-500',
offline: 'bg-red-500'
} as const;
interface ConversationListProps {
conversations: Conversation[];
selectedId: string;
onSelect: (id: string) => void;
}
export function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return conversations;
const q = search.toLowerCase();
return conversations.filter(
(c) => c.name.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
);
}, [conversations, search]);
return (
<div className='border-border/40 bg-background/75 hidden h-full flex-col gap-4 overflow-hidden rounded-2xl border p-3 backdrop-blur lg:col-start-1 lg:col-end-2 lg:flex lg:rounded-3xl lg:p-4'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-foreground text-sm font-semibold'>Messenger</p>
<p className='text-muted-foreground text-xs'>
{conversations.length} active conversation
{conversations.length === 1 ? '' : 's'}
</p>
</div>
<Badge
variant='outline'
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-3 py-1 text-[0.7rem] tracking-[0.24em] uppercase'
>
Live
</Badge>
</div>
<label htmlFor='messenger-search' className='sr-only'>
Search conversations
</label>
<div className='relative'>
<Icons.search
className='text-muted-foreground/70 pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2'
aria-hidden='true'
/>
<Input
id='messenger-search'
type='search'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search conversations'
className='border-border/40 bg-background/60 text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-primary/40 w-full rounded-2xl pl-10 text-sm focus-visible:ring-2'
/>
</div>
<div
className='flex-1 space-y-2 overflow-y-auto pr-1'
aria-label='Conversation list'
role='list'
>
{filtered.length === 0 ? (
<p className='text-muted-foreground py-8 text-center text-xs'>No conversations found</p>
) : null}
{filtered.map((conversation) => {
const isActive = conversation.id === selectedId;
const lastMessage = conversation.messages[conversation.messages.length - 1];
return (
<motion.button
key={conversation.id}
type='button'
onClick={() => onSelect(conversation.id)}
aria-current={isActive ? 'true' : undefined}
className={cn(
'focus-visible:ring-primary/50 group focus-visible:ring-offset-background relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
isActive
? 'border-primary/40 bg-primary/10'
: 'bg-background/70 hover:border-border/40 hover:bg-muted/40'
)}
role='listitem'
>
<div className='relative shrink-0'>
<Avatar className='border-border/40 bg-background/80 text-foreground h-10 w-10 rounded-2xl border'>
<AvatarFallback className='bg-primary/15 text-primary rounded-2xl text-sm font-medium'>
{conversation.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2',
statusDotColor[conversation.status]
)}
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
/>
</div>
<div className='min-w-0 flex-1 space-y-1'>
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 flex-1'>
<p className='text-foreground text-sm font-semibold'>{conversation.name}</p>
<p className='text-muted-foreground text-xs'>{conversation.title}</p>
</div>
{lastMessage && (
<span className='text-muted-foreground shrink-0 text-[0.65rem]'>
{lastMessage.timestamp}
</span>
)}
</div>
{lastMessage ? (
<p className='text-muted-foreground line-clamp-2 text-xs'>
{lastMessage.author}: {lastMessage.text}
</p>
) : (
<p className='text-muted-foreground text-xs'>No messages yet</p>
)}
</div>
{conversation.unread > 0 && (
<span className='bg-primary text-primary-foreground ml-2 inline-flex min-h-[1.5rem] min-w-[1.5rem] items-center justify-center rounded-full text-[0.7rem] font-semibold shadow-lg'>
{conversation.unread}
</span>
)}
</motion.button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Badge } from '@/components/ui/badge';
import type { Conversation } from '../utils/types';
interface ConversationSelectProps {
conversations: Conversation[];
selectedId: string;
onSelect: (id: string) => void;
}
export function ConversationSelect({
conversations,
selectedId,
onSelect
}: ConversationSelectProps) {
return (
<div className='border-border/40 bg-background/75 flex flex-col gap-3 rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:rounded-3xl sm:p-4 lg:hidden'>
<div className='flex items-center justify-between gap-2 sm:gap-3'>
<div>
<p className='text-foreground text-xs font-semibold sm:text-sm'>Messenger</p>
<p className='text-muted-foreground text-[0.65rem] sm:text-xs'>
{conversations.length} active conversation
{conversations.length === 1 ? '' : 's'}
</p>
</div>
<Badge
variant='outline'
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-2 py-0.5 text-[0.65rem] tracking-[0.2em] uppercase sm:px-3 sm:py-1 sm:text-[0.7rem] sm:tracking-[0.24em]'
>
Live
</Badge>
</div>
<div className='space-y-1.5 sm:space-y-2'>
<label
htmlFor='messenger-conversation'
className='text-muted-foreground text-[0.65rem] font-medium sm:text-xs'
>
Conversation
</label>
<select
id='messenger-conversation'
value={selectedId}
onChange={(e) => onSelect(e.target.value)}
className='border-border/40 bg-background/70 text-foreground focus:border-primary/40 focus:ring-primary/30 w-full rounded-xl border px-2.5 py-1.5 text-xs focus:ring-2 focus:outline-none sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm'
>
{conversations.map((conversation) => (
<option key={conversation.id} value={conversation.id}>
{conversation.name}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Icons } from '@/components/icons';
import { motion, useReducedMotion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FilePreview } from '@/components/ui/file-preview';
import type { Message } from '../utils/types';
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const shouldReduceMotion = useReducedMotion();
const isUser = message.sender === 'user';
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 12, scale: 0.98 }}
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }}
className='flex flex-col gap-1'
role='group'
aria-label={message.author + ' at ' + message.timestamp}
>
<div
className={cn(
'relative max-w-[85%] rounded-xl border px-3 py-2 text-xs leading-relaxed sm:max-w-[82%] sm:rounded-2xl sm:px-4 sm:py-3 sm:text-sm',
isUser
? 'border-primary/40 bg-primary text-primary-foreground ml-auto'
: 'bg-muted border-transparent'
)}
>
<p
className={cn(
'font-medium sm:text-sm',
isUser ? 'text-primary-foreground/80' : 'text-foreground/80'
)}
>
{message.author}
</p>
{message.text && (
<p
className={cn(
'mt-1 text-[0.875rem] sm:text-[0.95rem]',
isUser ? 'text-primary-foreground/90' : 'text-foreground/90'
)}
>
{message.text}
</p>
)}
{message.attachments && message.attachments.length > 0 && (
<FilePreview
files={message.attachments.map((a) => ({
id: a.id,
name: a.name,
type: a.type
}))}
variant={isUser ? 'inverted' : 'default'}
className='mt-1 p-0'
/>
)}
<div className='mt-2 flex items-center justify-end gap-1.5 text-[0.65rem] sm:mt-3 sm:gap-2 sm:text-[0.7rem]'>
<span className={cn('text-muted-foreground', isUser && 'text-primary-foreground/80')}>
{message.timestamp}
</span>
{isUser && (
<Icons.checks
className='text-primary-foreground/80 h-3 w-3 sm:h-3.5 sm:w-3.5'
aria-hidden='true'
/>
)}
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { FormEvent, useRef } from 'react';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { FilePreview } from '@/components/ui/file-preview';
import type { Attachment } from '../utils/types';
interface MessageComposerProps {
draft: string;
onDraftChange: (text: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
contactName: string;
quickReplies: string[];
attachments: Attachment[];
onAddAttachments: (files: FileList) => void;
onRemoveAttachment: (id: string) => void;
}
export function MessageComposer({
draft,
onDraftChange,
onSubmit,
contactName,
quickReplies,
attachments,
onAddAttachments,
onRemoveAttachment
}: MessageComposerProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<form onSubmit={onSubmit} className='space-y-2 sm:space-y-3' aria-label='Reply composer'>
<label htmlFor='messenger-editor' className='sr-only'>
Write a message
</label>
<div className='border-border/40 bg-background/80 flex items-end gap-2 rounded-2xl border p-3 backdrop-blur sm:gap-3 sm:rounded-3xl sm:p-4'>
<div className='min-w-0 flex-1'>
{attachments.length > 0 && (
<FilePreview
files={attachments.map((a) => ({
id: a.id,
name: a.name,
type: a.type
}))}
onRemove={onRemoveAttachment}
className='mb-1 p-0'
/>
)}
<Textarea
id='messenger-editor'
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (draft.trim() || attachments.length > 0) {
const form = e.currentTarget.closest('form');
form?.requestSubmit();
}
}
}}
placeholder={'Message ' + contactName + ' (Enter to send, Shift+Enter for newline)'}
rows={2}
className='text-foreground placeholder:text-muted-foreground/70 min-h-[3rem] w-full resize-none border-none bg-transparent text-xs focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm'
aria-label={'Message ' + contactName}
/>
<div className='mt-2 flex flex-wrap gap-1.5 sm:mt-3 sm:gap-2'>
{quickReplies.map((reply) => (
<button
key={reply}
type='button'
onClick={() => onDraftChange(reply)}
className='border-border/50 bg-background/70 text-muted-foreground hover:border-primary/40 hover:text-foreground focus-visible:ring-primary/40 focus-visible:ring-offset-background rounded-full border px-2.5 py-0.5 text-[0.65rem] transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:px-3 sm:py-1 sm:text-xs'
>
{reply}
</button>
))}
</div>
</div>
<div className='flex shrink-0 flex-col items-end gap-1.5 sm:w-24 sm:gap-2'>
<input
ref={fileInputRef}
type='file'
multiple
className='hidden'
onChange={(e) => {
if (e.target.files?.length) {
onAddAttachments(e.target.files);
}
// Reset so same file can be re-selected
e.target.value = '';
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
className='border-border/40 bg-background/70 text-muted-foreground hover:bg-muted/50 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
aria-label='Attach a file'
onClick={() => fileInputRef.current?.click()}
>
<Icons.paperclip className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<Button
type='submit'
size='icon'
className='bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full shadow-lg transition focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 sm:size-10'
disabled={!draft.trim() && attachments.length === 0}
aria-label='Send message'
>
<Icons.send className='h-3.5 w-3.5 sm:h-4 sm:w-4' aria-hidden='true' />
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useReducedMotion } from 'motion/react';
import { useChatStore } from '../utils/store';
import type { Attachment, Message } from '../utils/types';
import { ConversationList } from './conversation-list';
import { ConversationSelect } from './conversation-select';
import { ChatArea } from './chat-area';
export function Messenger() {
const {
conversations,
selectedConversationId,
draft,
replyCursor,
selectConversation,
setDraft,
sendMessage,
addIncomingMessage,
advanceReplyCursor,
getActiveConversation
} = useChatStore();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const shouldReduceMotion = useReducedMotion();
const replyTimeoutRef = useRef<number | null>(null);
const selectedRef = useRef(selectedConversationId);
useEffect(() => {
selectedRef.current = selectedConversationId;
setAttachments([]);
}, [selectedConversationId]);
useEffect(() => {
return () => {
if (replyTimeoutRef.current) {
window.clearTimeout(replyTimeoutRef.current);
}
};
}, []);
const handleAddAttachments = useCallback((files: FileList) => {
const newAttachments: Attachment[] = Array.from(files).map((file) => ({
id: 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
name: file.name,
size: file.size,
type: file.type
}));
setAttachments((prev) => [...prev, ...newAttachments]);
}, []);
const handleRemoveAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const active = getActiveConversation();
if ((!draft.trim() && attachments.length === 0) || !active) return;
const conversationId = active.id;
sendMessage(draft, attachments.length > 0 ? attachments : undefined);
setAttachments([]);
const autoReplies = active.autoReplies;
if (!autoReplies.length) return;
const cursor = replyCursor[conversationId] ?? 0;
const nextReply = autoReplies[cursor % autoReplies.length];
const delay = shouldReduceMotion ? 0 : 900;
replyTimeoutRef.current = window.setTimeout(() => {
const timestamp = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
const incoming: Message = {
id: 'incoming-' + Date.now().toString(),
sender: 'contact',
author: active.name,
text: nextReply,
timestamp
};
addIncomingMessage(conversationId, incoming);
advanceReplyCursor(conversationId);
}, delay);
},
[
draft,
attachments,
replyCursor,
shouldReduceMotion,
getActiveConversation,
sendMessage,
addIncomingMessage,
advanceReplyCursor
]
);
const activeConversation = getActiveConversation();
if (!activeConversation) return null;
return (
<div className='border-border/50 bg-background/70 relative grid h-[calc(100dvh-5.5rem)] w-full grid-rows-[auto,1fr] gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur-xl sm:gap-4 sm:p-4 lg:[grid-template-columns:30%_1fr] lg:grid-rows-[1fr] lg:gap-4 lg:rounded-3xl lg:p-5'>
<ConversationSelect
conversations={conversations}
selectedId={selectedConversationId}
onSelect={selectConversation}
/>
<ConversationList
conversations={conversations}
selectedId={selectedConversationId}
onSelect={selectConversation}
/>
<ChatArea
conversation={activeConversation}
draft={draft}
onDraftChange={setDraft}
onSubmit={handleSubmit}
attachments={attachments}
onAddAttachments={handleAddAttachments}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import type { Conversation } from './types';
export const initialConversations: Conversation[] = [
{
id: 'billing-issue',
name: 'Alex from Support',
title: 'Billing Issue #4821',
status: 'online',
unread: 2,
initials: 'AS',
messages: [
{
id: 'billing-1',
sender: 'contact',
author: 'Alex',
text: "Hi there! I can see you were charged twice for the Pro plan this month. I've already initiated a refund for the duplicate charge.",
timestamp: '10:02'
},
{
id: 'billing-2',
sender: 'user',
author: 'You',
text: 'Thanks for catching that. How long will the refund take to process?',
timestamp: '10:05'
},
{
id: 'billing-3',
sender: 'contact',
author: 'Alex',
text: 'Typically 3-5 business days depending on your bank. You should see a pending credit within 24 hours though. Is there anything else I can help with?',
timestamp: '10:08'
}
],
quickReplies: [
"That's perfect, thank you!",
'Can I get a receipt for the refund?',
'I also have a question about upgrading.'
],
autoReplies: [
"You're welcome! I've also applied a 10% discount on your next billing cycle as an apology for the inconvenience.",
'Absolutely — I just emailed the refund confirmation to your registered address.',
"Of course! I'd be happy to walk you through the available plans."
]
},
{
id: 'api-integration',
name: 'Priya from Engineering',
title: 'API Integration Help',
status: 'online',
unread: 0,
initials: 'PE',
messages: [
{
id: 'api-1',
sender: 'user',
author: 'You',
text: "I'm getting a 429 rate limit error when calling the /api/products endpoint. We're only making about 50 requests per minute.",
timestamp: '09:15'
},
{
id: 'api-2',
sender: 'contact',
author: 'Priya',
text: "I checked your API key — it's on the Starter tier which has a 30 req/min limit. You'll need the Growth plan for 200 req/min. Would you like me to upgrade it?",
timestamp: '09:18'
},
{
id: 'api-3',
sender: 'user',
author: 'You',
text: 'Yes please. Also, is there a way to implement retry logic that respects the Retry-After header?',
timestamp: '09:22'
},
{
id: 'api-4',
sender: 'contact',
author: 'Priya',
text: "Great question — our SDK handles this automatically if you enable `autoRetry: true` in the config. I'll send you a code snippet.",
timestamp: '09:25'
}
],
quickReplies: [
'That would be very helpful.',
'Can you also share the rate limit docs?',
"We're also seeing timeouts on the webhook endpoint."
],
autoReplies: [
"Here's the code snippet — just add `autoRetry: true` and `maxRetries: 3` to your client config.",
"Sure! I've shared the rate limiting guide in your inbox. It covers burst limits too.",
"Let me check the webhook logs for your account. Can you share the endpoint URL you're using?"
]
},
{
id: 'account-access',
name: 'Jordan from Security',
title: 'Account Access Request',
status: 'offline',
unread: 1,
initials: 'JS',
messages: [
{
id: 'access-1',
sender: 'contact',
author: 'Jordan',
text: "We noticed a login attempt from an unrecognized device in São Paulo. Was this you? We've temporarily locked the session as a precaution.",
timestamp: 'Yesterday'
},
{
id: 'access-2',
sender: 'user',
author: 'You',
text: "No, that wasn't me. I'm based in New York. Can you revoke that session and enable 2FA on my account?",
timestamp: 'Yesterday'
}
],
quickReplies: [
'Can I also see a list of all active sessions?',
'Please reset my password as well.',
'Has any data been accessed from that session?'
],
autoReplies: [
"I've revoked all sessions except your current one and enabled 2FA. You'll get an email with the setup QR code.",
"Done — you'll receive a password reset link shortly. Make sure to use a unique password.",
'No data was accessed — the session was blocked before any API calls were made. Your account is secure.'
]
}
];

View File

@@ -0,0 +1,93 @@
import { create } from 'zustand';
// import { persist } from 'zustand/middleware';
import type { Attachment, Conversation, Message } from './types';
import { initialConversations } from './data';
type ReplyCursorState = Record<string, number>;
type ChatState = {
conversations: Conversation[];
selectedConversationId: string;
draft: string;
replyCursor: ReplyCursorState;
selectConversation: (id: string) => void;
setDraft: (text: string) => void;
sendMessage: (text: string, attachments?: Attachment[]) => void;
addIncomingMessage: (conversationId: string, message: Message) => void;
advanceReplyCursor: (conversationId: string) => void;
getActiveConversation: () => Conversation | undefined;
};
export const useChatStore = create<ChatState>()(
// To enable persistence across refreshes, uncomment the persist wrapper below:
// persist(
(set, get) => ({
conversations: initialConversations,
selectedConversationId: initialConversations[0]?.id ?? '',
draft: '',
replyCursor: Object.fromEntries(initialConversations.map((c) => [c.id, 0])),
selectConversation: (id) =>
set((state) => ({
selectedConversationId: id,
conversations: state.conversations.map((c) => (c.id === id ? { ...c, unread: 0 } : c))
})),
setDraft: (text) => set({ draft: text }),
sendMessage: (text, attachments) => {
const state = get();
const timestamp = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
const outgoing: Message = {
id: 'outgoing-' + Date.now().toString(),
sender: 'user',
author: 'You',
text: text.trim(),
timestamp,
attachments: attachments?.length ? attachments : undefined
};
set({
draft: '',
conversations: state.conversations.map((c) =>
c.id === state.selectedConversationId
? { ...c, messages: [...c.messages, outgoing], unread: 0 }
: c
)
});
},
addIncomingMessage: (conversationId, message) =>
set((state) => ({
conversations: state.conversations.map((c) => {
if (c.id !== conversationId) return c;
const isActive = state.selectedConversationId === conversationId;
return {
...c,
messages: [...c.messages, message],
unread: isActive ? 0 : c.unread + 1
};
})
})),
advanceReplyCursor: (conversationId) =>
set((state) => ({
replyCursor: {
...state.replyCursor,
[conversationId]: (state.replyCursor[conversationId] ?? 0) + 1
}
})),
getActiveConversation: () => {
const state = get();
return state.conversations.find((c) => c.id === state.selectedConversationId);
}
})
// ,
// { name: 'chat' }
// )
);

View File

@@ -0,0 +1,29 @@
export type Attachment = {
id: string;
name: string;
size: number;
type: string;
};
export type Message = {
id: string;
sender: 'user' | 'contact';
author: string;
text: string;
timestamp: string;
attachments?: Attachment[];
};
export type ConversationStatus = 'online' | 'offline';
export type Conversation = {
id: string;
name: string;
title: string;
status: ConversationStatus;
unread: number;
initials: string;
messages: Message[];
quickReplies: string[];
autoReplies: string[];
};