Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 313 additions & 0 deletions components/InquiryChat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
/**
* InquiryChat Component
*
* Conversational chat interface for New Inquiry
* ChatGPT-style UI with message history and AI responses
*
* C-151: Conversational Inquiry Chat Interface
*/

import React, { useState, useRef, useEffect, useCallback } from 'react';

// Message type
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}

// Props
interface InquiryChatProps {
onClose?: () => void;
apiBaseUrl?: string;
authToken?: string;
userName?: string;
}

// Quick suggestion chips
const QUICK_SUGGESTIONS = [
'What is MIC?',
'How do I earn?',
'Explain integrity',
'Tell me about Mobius',
];

export default function InquiryChat({
onClose,
apiBaseUrl = '/api/inquiry',
authToken,
userName,
}: InquiryChatProps) {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'assistant',
content: `Hi${userName ? ` ${userName}` : ''}! 👋 I'm here to help you explore Mobius Systems. Ask me anything about MIC, integrity, learning modules, or how to get started!`,
timestamp: new Date(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);

// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);

useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);

// Auto-resize textarea
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`;
}
}, [inputValue]);

// Generate unique ID
const generateId = () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;

// Send message
const sendMessage = async (content: string) => {
if (!content.trim() || isLoading) return;

setError(null);
const userMessage: Message = {
id: generateId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};

setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);

try {
// Prepare history for context (exclude welcome message)
const history = messages
.filter((m) => m.id !== 'welcome')
.map((m) => ({ role: m.role, content: m.content }));

const response = await fetch(`${apiBaseUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
},
body: JSON.stringify({
message: content.trim(),
history,
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to get response');
}

const data = await response.json();

const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: data.response,
timestamp: new Date(),
};

setMessages((prev) => [...prev, assistantMessage]);
} catch (err: any) {
console.error('Chat error:', err);
setError(err.message || 'Something went wrong. Please try again.');

// Add error message to chat
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'assistant',
content: "I apologize, but I'm having trouble responding right now. Please try again in a moment.",
timestamp: new Date(),
},
]);
} finally {
setIsLoading(false);
}
};

// Handle form submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage(inputValue);
};

// Handle Enter key (Shift+Enter for newline)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(inputValue);
}
};

// Handle quick suggestion click
const handleSuggestion = (suggestion: string) => {
sendMessage(suggestion);
};

return (
<div className="flex flex-col h-full bg-gray-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center">
<span className="text-xl">🤖</span>
</div>
<div>
<h2 className="font-semibold">New Inquiry</h2>
<p className="text-xs text-white/80">Ask me anything</p>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
aria-label="Close chat"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>

{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
message.role === 'user'
? 'bg-gradient-to-br from-blue-500 to-purple-500 text-white'
: 'bg-white text-gray-800 shadow-md border border-gray-100'
}`}
>
{/* Message content with markdown-like formatting */}
<div className="text-sm whitespace-pre-wrap break-words">
{message.content.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < message.content.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
{/* Timestamp */}
<div
className={`text-xs mt-1 ${
message.role === 'user' ? 'text-white/70' : 'text-gray-400'
}`}
>
{message.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
))}

{/* Loading indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white text-gray-800 shadow-md border border-gray-100 rounded-2xl px-4 py-3">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}

{/* Error message */}
{error && (
<div className="flex justify-center">
<div className="bg-red-50 text-red-600 text-sm px-4 py-2 rounded-lg border border-red-200">
{error}
</div>
</div>
)}

{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>

{/* Quick suggestions (only show if few messages) */}
{messages.length <= 2 && !isLoading && (
<div className="px-4 pb-2">
<div className="flex flex-wrap gap-2">
{QUICK_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
onClick={() => handleSuggestion(suggestion)}
className="px-3 py-1.5 text-xs bg-white border border-gray-200 rounded-full text-gray-600 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-600 transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}

{/* Input area */}
<form onSubmit={handleSubmit} className="p-4 bg-white border-t border-gray-200">
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
disabled={isLoading}
rows={1}
className="w-full px-4 py-3 pr-12 bg-gray-100 rounded-2xl border-0 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all text-sm"
style={{ minHeight: '48px', maxHeight: '120px' }}
/>
</div>
<button
type="submit"
disabled={!inputValue.trim() || isLoading}
className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all ${
inputValue.trim() && !isLoading
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:shadow-lg transform hover:scale-105'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
aria-label="Send message"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
</div>
<p className="text-xs text-gray-400 mt-2 text-center">
Press Enter to send, Shift+Enter for new line
</p>
</form>
</div>
);
}
Loading
Loading