"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 { 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 PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))" 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 (

Prefer a fuller intake? Use our{" "} {formLabel} .

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

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

) } return (

Need a fuller request? Use the{" "} {formLabel} .

) } export function SiteChatWidget() { const pathname = usePathname() const bootstrap = useMemo(() => getSiteChatBootstrap(), []) const intentOptions = useMemo( () => CHAT_INTENT_OPTIONS.map((option) => ({ label: option, value: option })), [], ) const isSuppressed = isSiteChatSuppressedRoute(pathname) 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 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]) 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 } return (
{isOpen ? (

{bootstrap.assistantName}

Text support

{!profile ? (

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 || "/", })) } onMarketingChange={() => undefined} serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined} />
Call
{profileError ? (

{profileError}

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