- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
6.7 KiB
TypeScript
215 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { Send } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
|
|
type AIProvider = 'claude' | 'openai';
|
|
|
|
interface ChatComposerProps {
|
|
/** Callback when a message is sent */
|
|
onSend: (message: string, provider?: AIProvider) => void;
|
|
/** Whether the input is disabled */
|
|
disabled?: boolean;
|
|
/** Whether a response is currently streaming */
|
|
isStreaming?: boolean;
|
|
/** Show provider selector dropdown */
|
|
showProviderSelector?: boolean;
|
|
}
|
|
|
|
/**
|
|
* ChatComposer - Message input area for the Control Center
|
|
*
|
|
* Features auto-resizing textarea, send button, optional provider selector,
|
|
* and keyboard shortcuts for sending messages.
|
|
*/
|
|
export const ChatComposer: React.FC<ChatComposerProps> = ({
|
|
onSend,
|
|
disabled = false,
|
|
isStreaming = false,
|
|
showProviderSelector = false,
|
|
}) => {
|
|
const [message, setMessage] = useState('');
|
|
const [provider, setProvider] = useState<AIProvider>('claude');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const isDisabled = disabled || isStreaming;
|
|
const canSend = message.trim().length > 0 && !isDisabled;
|
|
|
|
// Auto-resize textarea based on content
|
|
const adjustTextareaHeight = useCallback(() => {
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
// Reset height to auto to get the correct scrollHeight
|
|
textarea.style.height = 'auto';
|
|
// Set height to scrollHeight, with min and max constraints
|
|
const newHeight = Math.min(Math.max(textarea.scrollHeight, 44), 200);
|
|
textarea.style.height = `${newHeight}px`;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
adjustTextareaHeight();
|
|
}, [message, adjustTextareaHeight]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
if (!canSend) return;
|
|
|
|
const trimmedMessage = message.trim();
|
|
onSend(trimmedMessage, showProviderSelector ? provider : undefined);
|
|
setMessage('');
|
|
|
|
// Reset textarea height after clearing
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = '44px';
|
|
}
|
|
}, [canSend, message, onSend, provider, showProviderSelector]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Ctrl/Cmd + Enter to send
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
},
|
|
[handleSend]
|
|
);
|
|
|
|
const handleChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setMessage(e.target.value);
|
|
},
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'bg-[#F0F4F8]',
|
|
'rounded-2xl',
|
|
'p-4',
|
|
'shadow-[6px_6px_12px_#bfc3cc,-6px_-6px_12px_#ffffff]',
|
|
'transition-all duration-300'
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
{/* Provider selector (optional) */}
|
|
{showProviderSelector && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600">Provider:</span>
|
|
<Select
|
|
value={provider}
|
|
onValueChange={(value: AIProvider) => setProvider(value)}
|
|
disabled={isDisabled}
|
|
>
|
|
<SelectTrigger
|
|
className={cn(
|
|
'w-[140px] h-8',
|
|
'bg-[#F0F4F8]',
|
|
'border-0',
|
|
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.8)]',
|
|
'rounded-lg',
|
|
'text-sm',
|
|
'focus:ring-indigo-500'
|
|
)}
|
|
>
|
|
<SelectValue placeholder="Select provider" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="claude">Claude</SelectItem>
|
|
<SelectItem value="openai">OpenAI</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main input area */}
|
|
<div className="flex items-end gap-3">
|
|
<div className="flex-1 relative">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={message}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask me anything about your GHL account..."
|
|
disabled={isDisabled}
|
|
rows={1}
|
|
className={cn(
|
|
'w-full',
|
|
'min-h-[44px]',
|
|
'max-h-[200px]',
|
|
'px-4 py-3',
|
|
'bg-[#F0F4F8]',
|
|
'border-0',
|
|
'rounded-xl',
|
|
'shadow-[inset_3px_3px_6px_rgba(0,0,0,0.06),inset_-3px_-3px_6px_rgba(255,255,255,0.9)]',
|
|
'text-gray-800',
|
|
'placeholder:text-gray-400',
|
|
'resize-none',
|
|
'focus:outline-none',
|
|
'focus:ring-2',
|
|
'focus:ring-indigo-400',
|
|
'focus:ring-offset-0',
|
|
'transition-all duration-200',
|
|
'disabled:opacity-50',
|
|
'disabled:cursor-not-allowed',
|
|
'text-sm md:text-base'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Send button */}
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!canSend}
|
|
aria-label="Send message"
|
|
className={cn(
|
|
'flex items-center justify-center',
|
|
'w-11 h-11',
|
|
'rounded-xl',
|
|
'transition-all duration-200',
|
|
canSend
|
|
? [
|
|
'bg-indigo-500',
|
|
'text-white',
|
|
'shadow-[4px_4px_8px_#bfc3cc,-4px_-4px_8px_#ffffff]',
|
|
'hover:bg-indigo-600',
|
|
'hover:shadow-[2px_2px_4px_#bfc3cc,-2px_-2px_4px_#ffffff]',
|
|
'active:shadow-[inset_2px_2px_4px_rgba(0,0,0,0.1)]',
|
|
]
|
|
: [
|
|
'bg-[#F0F4F8]',
|
|
'text-gray-400',
|
|
'shadow-[inset_2px_2px_4px_rgba(0,0,0,0.05),inset_-2px_-2px_4px_rgba(255,255,255,0.8)]',
|
|
'cursor-not-allowed',
|
|
]
|
|
)}
|
|
>
|
|
<Send className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Keyboard shortcut hint */}
|
|
<div className="flex justify-end">
|
|
<span className="text-xs text-gray-400">
|
|
Press <kbd className="px-1.5 py-0.5 bg-gray-200 rounded text-gray-600 font-mono">Ctrl</kbd>
|
|
{' + '}
|
|
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded text-gray-600 font-mono">Enter</kbd>
|
|
{' to send'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ChatComposer;
|