Rocky_Mountain_Vending/components/site-chat-widget.tsx

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>
)
}