Rocky_Mountain_Vending/components/site-chat-widget.tsx

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&apos;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>
)
}