858 lines
26 KiB
TypeScript
858 lines
26 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 {
|
|
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<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">
|
|
Ready to get started? Use{" "}
|
|
<Link
|
|
href={formHref}
|
|
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
|
>
|
|
{formLabel}
|
|
</Link>{" "}
|
|
and we'll help you plan the right setup.
|
|
</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 more help? Use{" "}
|
|
<Link
|
|
href={formHref}
|
|
className="font-medium text-foreground underline decoration-primary/35 underline-offset-4 hover:decoration-primary"
|
|
>
|
|
{formLabel}
|
|
</Link>{" "}
|
|
and our team will follow up.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
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<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 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<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
|
|
}
|
|
|
|
const renderHeader = (isMobileLayout: boolean) => (
|
|
<div
|
|
className={cn(
|
|
"flex items-start justify-between border-b border-border/70 px-4 py-4",
|
|
isMobileLayout && "px-5 pt-3"
|
|
)}
|
|
>
|
|
<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 for service, sales, and placement questions
|
|
</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-white 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>
|
|
)
|
|
|
|
const renderProfileGate = (isMobileLayout: boolean) => (
|
|
<div
|
|
className={cn(
|
|
"min-h-0 flex-1 overflow-y-auto px-4",
|
|
isMobileLayout ? "pb-6 pt-4" : "py-4"
|
|
)}
|
|
>
|
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
|
<div className="rounded-[1.5rem] border border-border/70 bg-white 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-white 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}
|
|
onServiceChange={(checked) =>
|
|
setProfileDraft((current) => ({
|
|
...current,
|
|
serviceTextConsent: checked,
|
|
consentVersion: SMS_CONSENT_VERSION,
|
|
consentSourcePage: pathname || "/",
|
|
}))
|
|
}
|
|
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-white 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>
|
|
)
|
|
|
|
const renderConversation = (isMobileLayout: boolean) => (
|
|
<>
|
|
<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={cn(
|
|
"min-h-0 flex-1 overflow-y-auto px-4 py-4",
|
|
isMobileLayout ? "pb-3" : "max-h-[22rem]"
|
|
)}
|
|
>
|
|
<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-white 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={isMobileLayout ? 4 : 3}
|
|
disabled={isSending}
|
|
className="min-h-24 w-full rounded-2xl border border-border/70 bg-white 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-white 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>
|
|
</>
|
|
)
|
|
|
|
const renderPanel = (isMobileLayout: boolean) => (
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
|
|
{renderHeader(isMobileLayout)}
|
|
{!profile
|
|
? renderProfileGate(isMobileLayout)
|
|
: renderConversation(isMobileLayout)}
|
|
</div>
|
|
)
|
|
|
|
if (isMobileViewport) {
|
|
return (
|
|
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
|
{!isOpen ? (
|
|
<div
|
|
className="fixed inset-x-0 z-40 flex justify-center px-4"
|
|
style={{
|
|
bottom: "calc(env(safe-area-inset-bottom, 0px) + 0.75rem)",
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid="site-chat-launcher"
|
|
onClick={() => setIsOpen(true)}
|
|
className="inline-flex min-h-14 w-full max-w-sm items-center justify-between gap-3 rounded-full border border-white/70 bg-white/96 px-4 py-3 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}`}
|
|
>
|
|
<div className="flex items-center gap-3 text-left">
|
|
<AssistantAvatar
|
|
src={bootstrap.avatarSrc}
|
|
alt={bootstrap.assistantName}
|
|
sizeClassName="h-10 w-10"
|
|
/>
|
|
<div>
|
|
<div className="text-sm font-semibold text-foreground">
|
|
Chat with {bootstrap.assistantName}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Service, sales, repairs, and placement help
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<MessageSquare className="h-5 w-5 text-primary" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<DrawerContent
|
|
className="max-h-[85dvh] rounded-t-[1.75rem] border-border/70 bg-white px-0"
|
|
style={{ maxHeight: MOBILE_DRAWER_MAX_HEIGHT }}
|
|
>
|
|
<DrawerTitle className="sr-only">
|
|
Chat with {bootstrap.assistantName}
|
|
</DrawerTitle>
|
|
<DrawerDescription className="sr-only">
|
|
Ask about service, repairs, sales, or free placement.
|
|
</DrawerDescription>
|
|
{renderPanel(true)}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
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-white shadow-[0_24px_80px_rgba(0,0,0,0.2)]"
|
|
style={{ maxHeight: DESKTOP_PANEL_MAX_HEIGHT }}
|
|
>
|
|
{renderPanel(false)}
|
|
</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-white 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>
|
|
)
|
|
}
|