629 lines
23 KiB
TypeScript
629 lines
23 KiB
TypeScript
"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<ChatVisitorProfile> | 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 (
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
Prefer a fuller intake? Use our{" "}
|
|
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
|
{formLabel}
|
|
</Link>
|
|
.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
if (isRepairOrMovingIntent(intent)) {
|
|
return (
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
For repairs or moving, text photos or videos to{" "}
|
|
<a href={smsUrl} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
|
{smsNumber}
|
|
</a>{" "}
|
|
or use the{" "}
|
|
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
|
{formLabel}
|
|
</Link>
|
|
.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
Need a fuller request? Use the{" "}
|
|
<Link href={formHref} className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary">
|
|
{formLabel}
|
|
</Link>
|
|
.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
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<ChatMessage[]>([])
|
|
const [draft, setDraft] = useState("")
|
|
const [profileDraft, setProfileDraft] = useState<ChatVisitorProfile>(() => createEmptyProfileDraft(pathname || "/"))
|
|
const [profile, setProfile] = useState<ChatVisitorProfile | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [profileError, setProfileError] = useState<string | null>(null)
|
|
const [isSending, setIsSending] = useState(false)
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [, setLimits] = useState<ChatLimitStatus | null>(null)
|
|
const messagesEndRef = useRef<HTMLDivElement | null>(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<HTMLFormElement>) => {
|
|
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<HTMLFormElement>) => {
|
|
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 (
|
|
<div
|
|
className="pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-3"
|
|
style={{ bottom: "calc(env(safe-area-inset-bottom, 0px) + 1rem)" }}
|
|
>
|
|
{isOpen ? (
|
|
<div
|
|
data-testid="site-chat-panel"
|
|
className="pointer-events-auto flex w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-[1.75rem] border border-border/70 bg-background/95 shadow-[0_24px_80px_rgba(0,0,0,0.2)] backdrop-blur-xl"
|
|
style={{ maxHeight: PANEL_MAX_HEIGHT }}
|
|
>
|
|
<div className="flex items-start justify-between border-b border-border/70 px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-11 w-11" />
|
|
<div>
|
|
<p className="text-base font-semibold text-foreground">{bootstrap.assistantName}</p>
|
|
<p className="text-xs text-muted-foreground">Text support</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(false)}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-foreground transition hover:border-primary/50 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
|
aria-label="Close chat"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{!profile ? (
|
|
<div className="min-h-0 overflow-y-auto px-4 py-4">
|
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
|
<div className="rounded-[1.5rem] border border-border/70 bg-background/80 p-4 shadow-sm">
|
|
<p className="text-sm font-medium text-foreground">Start with your details</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
We use this to route the conversation to the right team member.
|
|
</p>
|
|
|
|
<div className="mt-4 space-y-4">
|
|
<FormInput
|
|
id="site-chat-name"
|
|
label="Name"
|
|
value={profileDraft.name}
|
|
onChange={(event) => setProfileDraft((current) => ({ ...current, name: event.target.value }))}
|
|
autoComplete="name"
|
|
required
|
|
/>
|
|
<FormInput
|
|
id="site-chat-phone"
|
|
label="Phone"
|
|
value={profileDraft.phone}
|
|
onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))}
|
|
autoComplete="tel"
|
|
inputMode="tel"
|
|
type="tel"
|
|
required
|
|
/>
|
|
<FormInput
|
|
id="site-chat-email"
|
|
label="Email"
|
|
value={profileDraft.email}
|
|
onChange={(event) => setProfileDraft((current) => ({ ...current, email: event.target.value }))}
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
type="email"
|
|
required
|
|
/>
|
|
<FormSelect
|
|
id="site-chat-intent"
|
|
label="Intent"
|
|
value={profileDraft.intent}
|
|
onChange={(event) => setProfileDraft((current) => ({ ...current, intent: event.target.value }))}
|
|
options={intentOptions}
|
|
placeholder="Choose one"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[1.5rem] border border-border/70 bg-background/80 p-4 shadow-sm">
|
|
<p className="text-sm font-medium text-foreground">Text updates</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
Required service consent covers scheduling, support, repairs, moving, and follow-up texts for this request.
|
|
</p>
|
|
<div className="mt-4">
|
|
<SmsConsentFields
|
|
idPrefix="site-chat"
|
|
mode="chat"
|
|
serviceChecked={profileDraft.serviceTextConsent}
|
|
marketingChecked={profileDraft.marketingTextConsent}
|
|
onServiceChange={(checked) =>
|
|
setProfileDraft((current) => ({
|
|
...current,
|
|
serviceTextConsent: checked,
|
|
consentVersion: SMS_CONSENT_VERSION,
|
|
consentSourcePage: pathname || "/",
|
|
}))
|
|
}
|
|
onMarketingChange={() => undefined}
|
|
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<button
|
|
type="submit"
|
|
disabled={!profileDraftIsReady}
|
|
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
Start chat
|
|
</button>
|
|
|
|
<a
|
|
href={bootstrap.callUrl}
|
|
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
|
>
|
|
<Phone className="h-4 w-4" />
|
|
Call
|
|
</a>
|
|
</div>
|
|
|
|
<SupportHint
|
|
formHref={formHref}
|
|
formLabel={formLabel}
|
|
intent={activeIntent}
|
|
smsNumber={bootstrap.smsNumber}
|
|
smsUrl={bootstrap.smsUrl}
|
|
/>
|
|
</form>
|
|
|
|
{profileError ? (
|
|
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>{profileError}</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
|
<span>
|
|
Chatting as <span className="font-medium text-foreground">{profile.name}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleProfileReset}
|
|
className="inline-flex items-center gap-1 font-medium text-foreground transition hover:text-primary"
|
|
>
|
|
<SquarePen className="h-3.5 w-3.5" />
|
|
Edit details
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-[22rem] overflow-y-auto px-4 py-4">
|
|
<div className="space-y-3">
|
|
{messages.map((message) => (
|
|
<div key={message.id} className="space-y-1">
|
|
<div className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
|
{message.role === "assistant" ? bootstrap.assistantName : profile.name}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"max-w-[92%] rounded-2xl px-3 py-2 text-sm shadow-sm",
|
|
message.role === "assistant"
|
|
? "bg-muted text-foreground"
|
|
: "ml-auto bg-primary text-primary-foreground",
|
|
)}
|
|
>
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border/70 bg-background/95 px-4 py-4">
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
<label htmlFor="site-chat-input" className="text-sm font-semibold text-foreground">
|
|
Message
|
|
</label>
|
|
|
|
<textarea
|
|
id="site-chat-input"
|
|
data-testid="site-chat-input"
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value.slice(0, SITE_CHAT_MAX_INPUT_CHARS))}
|
|
placeholder="Describe what you need"
|
|
rows={3}
|
|
disabled={isSending}
|
|
className="min-h-24 w-full rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary focus:ring-4 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60"
|
|
/>
|
|
|
|
<SupportHint
|
|
formHref={formHref}
|
|
formLabel={formLabel}
|
|
intent={profile.intent}
|
|
smsNumber={bootstrap.smsNumber}
|
|
smsUrl={bootstrap.smsUrl}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<button
|
|
type="submit"
|
|
data-testid="site-chat-send"
|
|
disabled={isSending || !draft.trim()}
|
|
className="inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />}
|
|
{isSending ? "Sending..." : "Send"}
|
|
</button>
|
|
|
|
<a
|
|
href={bootstrap.callUrl}
|
|
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:border-primary/50 hover:text-primary"
|
|
>
|
|
<Phone className="h-4 w-4" />
|
|
Call
|
|
</a>
|
|
</div>
|
|
</form>
|
|
|
|
{error ? (
|
|
<div className="mt-3 rounded-2xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>{error}</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isOpen ? (
|
|
<button
|
|
type="button"
|
|
data-testid="site-chat-launcher"
|
|
onClick={() => setIsOpen(true)}
|
|
className="pointer-events-auto inline-flex h-14 w-14 items-center justify-center rounded-full border border-white/70 bg-background/95 shadow-[0_20px_60px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_24px_68px_rgba(0,0,0,0.22)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/20"
|
|
aria-label={`Open chat with ${bootstrap.assistantName}`}
|
|
>
|
|
<AssistantAvatar src={bootstrap.avatarSrc} alt={bootstrap.assistantName} sizeClassName="h-12 w-12" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|