Features: - Complete multi-step booking flow with service selection, date/time picker, auth (login/signup with email verification), payment, and confirmation - Business hours settings page for defining when business is open - TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE) - Service resource assignment with prep/takedown time buffers - Availability checking respects business hours and service buffers - Customer registration via email verification code UI/UX: - Full dark mode support for all booking components - Separate first/last name fields in signup form - Back buttons on each wizard step - Removed auto-redirect from confirmation page API: - Public endpoints for services, availability, business hours - Customer verification and registration endpoints - Tenant lookup from X-Business-Subdomain header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
135 lines
5.5 KiB
TypeScript
135 lines
5.5 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
|
|
import { BookingState, ChatMessage } from './types';
|
|
// TODO: Implement Gemini service
|
|
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
|
|
// Mock implementation - replace with actual Gemini API call
|
|
return "I'm here to help you book your appointment. Please use the booking form above.";
|
|
};
|
|
|
|
interface GeminiChatProps {
|
|
currentBookingState: BookingState;
|
|
}
|
|
|
|
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
|
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
|
|
]);
|
|
const [inputText, setInputText] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, isOpen]);
|
|
|
|
const handleSend = async () => {
|
|
if (!inputText.trim() || isLoading) return;
|
|
|
|
const userMsg: ChatMessage = { role: 'user', text: inputText };
|
|
setMessages(prev => [...prev, userMsg]);
|
|
setInputText('');
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
|
|
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
|
|
} catch (error) {
|
|
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
|
{/* Chat Window */}
|
|
{isOpen && (
|
|
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
|
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
|
|
<div className="flex items-center space-x-2">
|
|
<Sparkles className="w-4 h-4" />
|
|
<span className="font-semibold">Lumina Assistant</span>
|
|
</div>
|
|
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
|
|
{messages.map((msg, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`
|
|
max-w-[80%] px-4 py-2 rounded-2xl text-sm
|
|
${msg.role === 'user'
|
|
? 'bg-indigo-600 text-white rounded-br-none'
|
|
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
|
|
}
|
|
`}
|
|
>
|
|
{msg.text}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
|
|
<div className="flex space-x-1">
|
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<div className="p-3 bg-white border-t border-gray-100">
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<input
|
|
type="text"
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
placeholder="Ask about services..."
|
|
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || !inputText.trim()}
|
|
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toggle Button */}
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`
|
|
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
|
|
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
|
|
`}
|
|
style={{display: isOpen ? 'none' : 'flex'}}
|
|
>
|
|
<MessageCircle className="w-6 h-6 text-white" />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|