import React, { useState, useCallback } from "react"; import { Home, RefreshCw, TrendingUp, Key, CalendarDays, HelpCircle, Lock, Calendar, CheckCircle2, ArrowLeft, ArrowRight, ExternalLink, Video, Phone, MapPin, MessageSquare, User, } from "lucide-react"; import { cn } from "@/lib/utils"; import { resolveBrandVars } from "@/lib/colors"; import { safeWindowOpen } from "@/lib/safe-url"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { ChatWidgetLauncher, ChatWidgetHeader, ChatWidgetMessage, ChatWidgetInputBar, type ChatWidgetMessageRole, } from "@/components/ui/chat-widget-primitives"; /** * ChatWidget — WealthX DS (Broker Website Embeddable Chat) * * Two-screen flow: * Screen 1 — TopicGrid: lead selects a conversation topic * Screen 2 — Chat: AI conversation based on selected topic * * Exports (atomic order): * ChatWidgetIntakeForm — Molecule — Screen 1 contact form * ChatWidgetTopicCard — Molecule — Single topic option card * ChatWidgetTopicGrid — Organism — Screen 2 topic selection grid * ChatWidgetInteractiveCard — Molecule — AI-embedded interactive cards * ChatWidgetWindow — Template — 3-screen container + localStorage cache * ChatWidget — Root — Floating widget with FAB launcher * * Pure display component for story control. Real API wiring done in the app layer. */ // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- export interface ChatWidgetUser { name: string; phone: string; email: string; } export interface ChatWidgetTopic { id: string; icon: React.ReactNode; label: string; description?: string; } export interface ChatWidgetChatMessage { id: string; role: ChatWidgetMessageRole; content: string; timestamp?: string; isStreaming?: boolean; /** Optional inline interactive card rendered below the message bubble. */ interactiveCard?: ChatWidgetInteractiveCardData; } export type ChatWidgetScreen = "intake" | "topics" | "chat" | "booking"; // --------------------------------------------------------------------------- // Default topics // --------------------------------------------------------------------------- export const DEFAULT_CHAT_WIDGET_TOPICS: ChatWidgetTopic[] = [ { id: "buy_home", icon: , label: "Buy a Home", description: "Purchase your first or next property", }, { id: "refinance", icon: , label: "Refinance", description: "Get a better rate on your current loan", }, { id: "investment", icon: , label: "Investment Property", description: "Grow your property portfolio", }, { id: "first_home_buyer", icon: , label: "First Home Buyer", description: "First home buyer grants & schemes", }, { id: "book_meeting", icon: , label: "Book a Meeting", description: "Talk directly with an adviser", }, { id: "general_question", icon: , label: "General Question", description: "Ask anything about home loans", }, ]; // --------------------------------------------------------------------------- // ChatWidgetIntakeForm (Molecule — Screen 1) // --------------------------------------------------------------------------- export interface ChatWidgetIntakeFormProps { brokerName?: string; onSubmit: (user: ChatWidgetUser) => void; isSubmitting?: boolean; className?: string; } export function ChatWidgetIntakeForm({ brokerName, onSubmit, isSubmitting, className, }: ChatWidgetIntakeFormProps) { const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); const [errors, setErrors] = useState< Partial> >({}); const clearError = (field: keyof ChatWidgetUser) => setErrors((prev) => ({ ...prev, [field]: undefined })); const validate = (): Partial> => { const e: Partial> = {}; if (!name.trim()) e.name = "Name is required"; if (!phone.trim()) e.phone = "Phone number is required"; if (!email.trim()) e.email = "Email is required"; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = "Enter a valid email address"; return e; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const errs = validate(); if (Object.keys(errs).length > 0) { setErrors(errs); return; } onSubmit({ name: name.trim(), phone: phone.trim(), email: email.trim() }); }; return (

{brokerName ? `Hi from ${brokerName}!` : "Hi there!"}

To get started, please share your contact details.

Full Name * { setName(e.target.value); clearError("name"); }} placeholder="Jane Smith" disabled={isSubmitting} aria-invalid={!!errors.name || undefined} /> {errors.name && {errors.name}} Phone Number * { setPhone(e.target.value); clearError("phone"); }} placeholder="0400 000 000" disabled={isSubmitting} aria-invalid={!!errors.phone || undefined} /> {errors.phone && {errors.phone}} Email Address * { setEmail(e.target.value); clearError("email"); }} placeholder="jane@example.com" disabled={isSubmitting} aria-invalid={!!errors.email || undefined} /> {errors.email && {errors.email}}

Your info is safe with us

); } // --------------------------------------------------------------------------- // ChatWidgetTopicCard (Molecule — single topic) // --------------------------------------------------------------------------- export interface ChatWidgetTopicCardProps { topic: ChatWidgetTopic; onClick: (topicId: string) => void; className?: string; } export function ChatWidgetTopicCard({ topic, onClick, className, }: ChatWidgetTopicCardProps) { return ( ); } // --------------------------------------------------------------------------- // ChatWidgetTopicGrid (Organism — Screen 2) // --------------------------------------------------------------------------- export interface ChatWidgetTopicGridProps { userName: string; topics?: ChatWidgetTopic[]; onTopicSelect: (topicId: string) => void; className?: string; } export function ChatWidgetTopicGrid({ userName, topics = DEFAULT_CHAT_WIDGET_TOPICS, onTopicSelect, className, }: ChatWidgetTopicGridProps) { return (

Hi {userName}!

What would you like to discuss today?

{topics.map((topic) => ( ))}
); } // --------------------------------------------------------------------------- // ChatWidgetInteractiveCard (Molecule — AI-embedded cards) // --------------------------------------------------------------------------- export type ChatWidgetInteractiveCardType = | "quick-reply" | "appointment" | "meeting-type" | "confirmation"; export type ChatWidgetMeetingTypeId = "video" | "phone" | "in-person"; export interface ChatWidgetMeetingTypeOption { id: ChatWidgetMeetingTypeId; label: string; } export interface ChatWidgetQuickReplyOption { id: string; label: string; } export interface ChatWidgetAppointmentSlot { id: string; /** Human-readable datetime, e.g. "Mon 28 Apr · 2:00 PM" */ datetime: string; type: "video" | "in-person" | "phone"; } /** Data shape for an interactive card — no callbacks, safe to embed in message data. */ export interface ChatWidgetInteractiveCardData { type: ChatWidgetInteractiveCardType; options?: ChatWidgetQuickReplyOption[]; slots?: ChatWidgetAppointmentSlot[]; meetingTypes?: ChatWidgetMeetingTypeOption[]; confirmedSlot?: ChatWidgetAppointmentSlot; meetingType?: ChatWidgetMeetingTypeId; topic?: string; advisorName?: string; } export interface ChatWidgetInteractiveCardProps extends ChatWidgetInteractiveCardData { onQuickReply?: (optionId: string) => void; onSlotSelect?: (slotId: string) => void; onMeetingTypeSelect?: (typeId: ChatWidgetMeetingTypeId) => void; className?: string; } const MEETING_TYPE_LABELS: Record = { video: "Video Call", phone: "Phone Call", "in-person": "In-Person Meeting", }; function MeetingTypeIcon({ id, className = "size-4", }: { id: ChatWidgetMeetingTypeId; className?: string; }) { const Icon = id === "video" ? Video : id === "phone" ? Phone : MapPin; return ; } export function ChatWidgetInteractiveCard({ type, options, onQuickReply, slots, onSlotSelect, meetingTypes, onMeetingTypeSelect, confirmedSlot, meetingType, topic, advisorName, className, }: ChatWidgetInteractiveCardProps) { if (type === "quick-reply") { return (
{options?.map((opt) => ( ))}
); } if (type === "appointment") { return (

Choose a time

{slots?.map((slot) => ( ))}
); } if (type === "meeting-type") { return (

How would you prefer to meet?

{meetingTypes?.map((mt) => ( ))}
); } if (type === "confirmation") { return (

Appointment Confirmed

{confirmedSlot && (
{confirmedSlot.datetime}
)} {meetingType && (
{MEETING_TYPE_LABELS[meetingType]}
)} {topic && (
{topic}
)} {advisorName && (
with {advisorName}
)}
); } return null; } // --------------------------------------------------------------------------- // ChatWidgetWindow (Template — 3-screen container) // --------------------------------------------------------------------------- export interface ChatWidgetWindowProps { isOpen: boolean; brokerName?: string; /** * Solid brand color (hex) — sets `--primary` on the widget so all interactive * elements match, plus a contrast-safe `--primary-foreground` so text stays * readable on light colors. Omit to inherit the tenant color from ThemeProvider. */ brandColor?: string; gradientFrom?: string; gradientTo?: string; onMinimize?: () => void; // Screen 1 onIntakeSubmit?: (user: ChatWidgetUser) => void; isSubmittingIntake?: boolean; // Screen 2 topics?: ChatWidgetTopic[]; onTopicSelect?: (topicId: string) => void; /** External booking URL. When provided and user selects "Book a Meeting", shows a booking screen instead of chat. */ bookingUrl?: string; // Screen 3 messages?: ChatWidgetChatMessage[]; inputValue?: string; onInputChange?: (value: string) => void; onSend?: (value: string) => void; isStreaming?: boolean; onSlotSelect?: (slotId: string) => void; onQuickReply?: (optionId: string) => void; onMeetingTypeSelect?: (typeId: ChatWidgetMeetingTypeId) => void; /** Override starting screen — used in stories to skip to a specific screen. */ initialScreen?: ChatWidgetScreen; /** Override cached user — used in stories to pre-populate the user. */ initialUser?: ChatWidgetUser; className?: string; } export function ChatWidgetWindow({ isOpen, brokerName = "Your Broker", brandColor, gradientFrom, gradientTo, onMinimize, onIntakeSubmit, isSubmittingIntake, topics, onTopicSelect, bookingUrl, messages = [], inputValue = "", onInputChange, onSend, isStreaming, onSlotSelect, onQuickReply, onMeetingTypeSelect, initialScreen, initialUser, className, }: ChatWidgetWindowProps) { const [screen, setScreen] = useState( () => initialScreen ?? "topics", ); const [user, setUser] = useState( () => initialUser ?? null, ); const handleIntakeSubmit = useCallback( (data: ChatWidgetUser) => { setUser(data); setScreen("topics"); onIntakeSubmit?.(data); }, [onIntakeSubmit], ); const handleTopicSelect = useCallback( (topicId: string) => { setScreen(topicId === "book_meeting" && bookingUrl ? "booking" : "chat"); onTopicSelect?.(topicId); }, [onTopicSelect, bookingUrl], ); if (!isOpen) return null; const showBackBar = screen === "chat" || screen === "booking"; return (
{screen === "intake" && ( )} {screen === "topics" && ( )} {screen === "chat" && (
{messages.map((msg) => ( {(msg.content || msg.isStreaming) && ( )} {msg.interactiveCard && ( )} ))}
)} {screen === "booking" && bookingUrl && (

Book an Appointment

Choose a time that works for you with one of our advisers.

)}
{showBackBar && (
)} {screen === "chat" && onInputChange && onSend && ( )}
); } // --------------------------------------------------------------------------- // ChatWidget (Root orchestrator) // --------------------------------------------------------------------------- export interface ChatWidgetProps { brokerName?: string; /** * Solid brand color (hex) applied to the FAB launcher and the whole window. * Omit to inherit the tenant color from ThemeProvider. */ brandColor?: string; gradientFrom?: string; gradientTo?: string; /** ID used to connect to the correct AI agent. */ agentId?: string; /** External booking URL. When provided, "Book a Meeting" shows a direct booking screen instead of chat. */ bookingUrl?: string; position?: "bottom-right" | "bottom-left"; /** Opens the widget by default — useful for stories. */ defaultOpen?: boolean; /** Callback when a topic is selected. */ onTopicSelected?: (topicId: string) => void; /** Callback when the user sends a message. */ onMessageSent?: (message: string) => void; } export function ChatWidget({ brokerName = "Your Broker", brandColor, gradientFrom, gradientTo, bookingUrl, position = "bottom-right", defaultOpen = false, onTopicSelected, onMessageSent, }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(defaultOpen); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const handleTopicSelect = useCallback( (topicId: string) => { if (topicId !== "book_meeting" || !bookingUrl) { const topic = DEFAULT_CHAT_WIDGET_TOPICS.find((t) => t.id === topicId); if (topic) { setMessages([ { id: "welcome", role: "bot", content: `Great choice! I can help you with ${topic.label.toLowerCase()}. What would you like to know?`, }, ]); } } onTopicSelected?.(topicId); }, [onTopicSelected, bookingUrl], ); const handleSend = useCallback( (value: string) => { if (!value.trim()) return; setMessages((prev) => [ ...prev, { id: `msg-${Date.now()}`, role: "user", content: value }, ]); setInputValue(""); onMessageSent?.(value); }, [onMessageSent], ); return (
{isOpen && ( setIsOpen(false)} messages={messages} inputValue={inputValue} onInputChange={setInputValue} onSend={handleSend} onTopicSelect={handleTopicSelect} /> )} setIsOpen((prev) => !prev)} brandColor={brandColor} />
); }