"use client" import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react" import Link from "next/link" import { AlertCircle, Loader2, MessageSquare, Phone, SquarePen, X, } from "lucide-react" import { usePathname } from "next/navigation" import { AssistantAvatar } from "@/components/assistant-avatar" import { FormInput } from "@/components/forms/form-input" import { FormSelect } from "@/components/forms/form-select" import { SmsConsentFields } from "@/components/forms/sms-consent-fields" import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, } from "@/components/ui/drawer" import { cn } from "@/lib/utils" import { getSiteChatBootstrap, isSiteChatSuppressedRoute, SITE_CHAT_MAX_INPUT_CHARS, } from "@/lib/site-chat/config" import { CHAT_INTENT_OPTIONS, getBestIntentFormHref, getBestIntentFormLabel, isFreePlacementIntent, isRepairOrMovingIntent, } from "@/lib/site-chat/intents" import { SMS_CONSENT_VERSION } from "@/lib/sms-compliance" type ChatRole = "user" | "assistant" type ChatMessage = { id: string role: ChatRole content: string } type ChatVisitorProfile = { consentCapturedAt: string consentSourcePage: string consentVersion: string email: string intent: string marketingTextConsent: boolean name: string phone: string serviceTextConsent: boolean } type ChatLimitStatus = { ipRemaining: number sessionRemaining: number outputCharsRemaining: number requestResetAt: string outputResetAt: string blocked: boolean } type ChatApiResponse = { reply?: string error?: string sessionId?: string limits?: ChatLimitStatus } const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form." const SESSION_STORAGE_KEY = "rmv-site-chat-session" const PROFILE_STORAGE_KEY = "rmv-site-chat-profile" const DESKTOP_PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))" const MOBILE_DRAWER_MAX_HEIGHT = "min(48rem, calc(100dvh - 0.5rem))" const MOBILE_CHAT_MEDIA_QUERY = "(max-width: 767px)" function createMessage(role: ChatRole, content: string): ChatMessage { return { id: `${role}-${crypto.randomUUID()}`, role, content, } } function createEmptyProfileDraft( consentSourcePage: string ): ChatVisitorProfile { return { name: "", phone: "", email: "", intent: "", serviceTextConsent: false, marketingTextConsent: false, consentVersion: SMS_CONSENT_VERSION, consentCapturedAt: "", consentSourcePage, } } function normalizeProfile( value: Partial | null | undefined, fallbackSourcePage: string ): ChatVisitorProfile | null { if (!value) { return null } const profile = { name: String(value.name || "") .replace(/\s+/g, " ") .trim() .slice(0, 80), phone: String(value.phone || "") .replace(/\s+/g, " ") .trim() .slice(0, 40), email: String(value.email || "") .replace(/\s+/g, " ") .trim() .slice(0, 120) .toLowerCase(), intent: String(value.intent || "") .replace(/\s+/g, " ") .trim() .slice(0, 80), serviceTextConsent: Boolean(value.serviceTextConsent), marketingTextConsent: Boolean(value.marketingTextConsent), consentVersion: String(value.consentVersion || SMS_CONSENT_VERSION).trim() || SMS_CONSENT_VERSION, consentCapturedAt: typeof value.consentCapturedAt === "string" && value.consentCapturedAt.trim() ? value.consentCapturedAt : new Date().toISOString(), consentSourcePage: typeof value.consentSourcePage === "string" && value.consentSourcePage.trim() ? value.consentSourcePage.trim() : fallbackSourcePage, } const digits = profile.phone.replace(/\D/g, "") const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email) const timestampIsValid = !Number.isNaN(Date.parse(profile.consentCapturedAt)) if ( !profile.name || !profile.phone || !profile.email || !profile.intent || !profile.serviceTextConsent || digits.length < 10 || !emailIsValid || !timestampIsValid ) { return null } return profile } function createIntroMessage(profile: ChatVisitorProfile) { const firstName = profile.name.split(" ")[0] || profile.name const intentLabel = profile.intent.toLowerCase() return createMessage( "assistant", `Hi ${firstName}, I've got your ${intentLabel} request. How can I help?` ) } function SupportHint({ formHref, formLabel, intent, smsNumber, smsUrl, }: { formHref: string formLabel: string intent: string smsNumber: string smsUrl: string }) { if (isFreePlacementIntent(intent)) { return (

Ready to get started? Use{" "} {formLabel} {" "} and we'll help you plan the right setup.

) } if (isRepairOrMovingIntent(intent)) { return (

For repairs or moving, text photos or videos to{" "} {smsNumber} {" "} or use the{" "} {formLabel} .

) } return (

Need more help? Use{" "} {formLabel} {" "} and our team will follow up.

) } export function SiteChatWidget() { const pathname = usePathname() const bootstrap = useMemo(() => getSiteChatBootstrap(), []) const intentOptions = useMemo( () => CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })), [] ) const [isMobileViewport, setIsMobileViewport] = useState(false) const [isOpen, setIsOpen] = useState(false) const [messages, setMessages] = useState([]) const [draft, setDraft] = useState("") const [profileDraft, setProfileDraft] = useState(() => createEmptyProfileDraft(pathname || "/") ) const [profile, setProfile] = useState(null) const [error, setError] = useState(null) const [profileError, setProfileError] = useState(null) const [isSending, setIsSending] = useState(false) const [sessionId, setSessionId] = useState(null) const [, setLimits] = useState(null) const messagesEndRef = useRef(null) useEffect(() => { const mediaQuery = window.matchMedia(MOBILE_CHAT_MEDIA_QUERY) const handleViewportChange = () => setIsMobileViewport(mediaQuery.matches) handleViewportChange() if (typeof mediaQuery.addEventListener === "function") { mediaQuery.addEventListener("change", handleViewportChange) return () => mediaQuery.removeEventListener("change", handleViewportChange) } mediaQuery.addListener(handleViewportChange) return () => mediaQuery.removeListener(handleViewportChange) }, []) const isSuppressed = isSiteChatSuppressedRoute(pathname) useEffect(() => { const storedSessionId = window.localStorage.getItem(SESSION_STORAGE_KEY) if (storedSessionId) { setSessionId(storedSessionId) } const rawProfile = window.localStorage.getItem(PROFILE_STORAGE_KEY) if (!rawProfile) { setProfileDraft(createEmptyProfileDraft(pathname || "/")) return } try { const parsedProfile = normalizeProfile( JSON.parse(rawProfile), pathname || "/" ) if (!parsedProfile) { window.localStorage.removeItem(PROFILE_STORAGE_KEY) setProfileDraft(createEmptyProfileDraft(pathname || "/")) return } setProfile(parsedProfile) setProfileDraft(parsedProfile) setMessages([createIntroMessage(parsedProfile)]) } catch { window.localStorage.removeItem(PROFILE_STORAGE_KEY) setProfileDraft(createEmptyProfileDraft(pathname || "/")) } }, [pathname]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) }, [messages.length, isOpen]) useEffect(() => { if (!isSuppressed) { return } setIsOpen(false) }, [isSuppressed]) const activeIntent = profile?.intent || profileDraft.intent const formHref = getBestIntentFormHref(activeIntent) const formLabel = getBestIntentFormLabel(activeIntent) const profileDraftIsReady = Boolean( profileDraft.name.trim() && profileDraft.phone.trim() && profileDraft.email.trim() && profileDraft.intent && profileDraft.serviceTextConsent ) const handleProfileSubmit = useCallback( (event: FormEvent) => { event.preventDefault() const nextProfile = normalizeProfile( { ...profileDraft, consentCapturedAt: profileDraft.consentCapturedAt || new Date().toISOString(), consentSourcePage: pathname || "/", consentVersion: profileDraft.consentVersion || SMS_CONSENT_VERSION, }, pathname || "/" ) if (!nextProfile) { setProfileError( "Enter a valid name, phone, email, intent, and required service SMS consent." ) return } setProfile(nextProfile) setProfileDraft(nextProfile) setMessages([createIntroMessage(nextProfile)]) setProfileError(null) setError(null) window.localStorage.setItem( PROFILE_STORAGE_KEY, JSON.stringify(nextProfile) ) }, [pathname, profileDraft] ) const handleProfileReset = useCallback(() => { setProfile(null) setProfileDraft(createEmptyProfileDraft(pathname || "/")) setMessages([]) setProfileError(null) setError(null) window.localStorage.removeItem(PROFILE_STORAGE_KEY) }, [pathname]) const handleSubmit = useCallback( async (event: FormEvent) => { event.preventDefault() if (!profile) { setError("Enter your details before chatting.") return } const trimmed = draft.trim() if (!trimmed || isSending) { return } const nextUserMessage = createMessage("user", trimmed) const nextMessages = [...messages, nextUserMessage].slice(-12) setMessages(nextMessages) setDraft("") setError(null) setIsSending(true) try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ pathname, sessionId, visitor: profile, messages: nextMessages.map((message) => ({ role: message.role, content: message.content, })), }), }) const data = (await response.json()) as ChatApiResponse if (data.sessionId) { setSessionId(data.sessionId) window.localStorage.setItem(SESSION_STORAGE_KEY, data.sessionId) } if (data.limits) { setLimits(data.limits) } if (!response.ok || !data.reply) { throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE) } setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12) ) } catch (chatError) { const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE setError(message) setMessages((current) => [ ...current, createMessage( "assistant", "I'm temporarily unavailable right now. Please call us or use the contact form." ), ].slice(-12) ) } finally { setIsSending(false) } }, [draft, isSending, messages, pathname, profile, sessionId] ) if (isSuppressed) { return null } const renderHeader = (isMobileLayout: boolean) => (

{bootstrap.assistantName}

Text support for service, sales, and placement questions

) const renderProfileGate = (isMobileLayout: boolean) => (

Start with your details

We use this to route the conversation to the right team member.

setProfileDraft((current) => ({ ...current, name: event.target.value, })) } autoComplete="name" required /> setProfileDraft((current) => ({ ...current, phone: event.target.value, })) } autoComplete="tel" inputMode="tel" type="tel" required /> setProfileDraft((current) => ({ ...current, email: event.target.value, })) } autoComplete="email" inputMode="email" type="email" required /> setProfileDraft((current) => ({ ...current, intent: event.target.value, })) } options={intentOptions} placeholder="Choose one" required />

Text updates

Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.

setProfileDraft((current) => ({ ...current, serviceTextConsent: checked, consentVersion: SMS_CONSENT_VERSION, consentSourcePage: pathname || "/", })) } serviceError={ profileError && !profileDraft.serviceTextConsent ? profileError : undefined } />
Call
{profileError ? (

{profileError}

) : null}
) const renderConversation = (isMobileLayout: boolean) => ( <>
Chatting as{" "} {profile?.name}
{messages.map((message) => (
{message.role === "assistant" ? bootstrap.assistantName : profile?.name}
{message.content}
))}