BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- 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>
2026-01-14 17:30:55 -05:00

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;