since3-portal/modules/templates/ai/ui/chat-widget.tsx
2026-10-05 08:23:02 +02:00

192 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ArrowUpIcon, MessageCircleIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { brandName } from "../../lib/content"
import type { TemplateContent } from "../../lib/types"
import { useAiAccess } from "../access-context"
import { BookingCard } from "./booking-card"
import { useAiChat } from "./use-ai-chat"
const ease = [0.22, 1, 0.36, 1] as const
const QUICK_REPLIES = ["Wie sind Ihre Öffnungszeiten?", "Ich möchte einen Termin vereinbaren", "Was bieten Sie an?"]
// Chat-Assistent unten rechts; nutzt Palette und Schriften der Vorlage (bg-t-*, font-t-*)
export function ChatWidget({ content }: { content: TemplateContent }) {
const access = useAiAccess()
const chat = useAiChat({ access, mode: "chat" })
const [open, setOpen] = useState(false)
const [teaser, setTeaser] = useState(false)
const [draft, setDraft] = useState("")
const listRef = useRef<HTMLDivElement>(null)
const enabled = Boolean(access && content.ai.chat)
const name = content.ai.name.trim() || "Assistent"
const brand = brandName(content)
const greeting = content.ai.greeting.trim() || `Hallo! Ich bin ${name}${brand ? ` von ${brand}` : ""}. Wie kann ich Ihnen helfen?`
useEffect(() => {
if (!enabled) return
const timer = setTimeout(() => setTeaser(true), 4000)
return () => clearTimeout(timer)
}, [enabled])
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" })
}, [chat.messages, open])
if (!enabled) return null
const last = chat.messages[chat.messages.length - 1]
const typing = chat.pending && last?.role === "assistant" && last.parts.length === 0
function submit(text: string) {
if (!text.trim() || chat.pending) return
setDraft("")
void chat.send(text)
}
return (
<div className="fixed right-4 bottom-[calc(1rem+var(--t-safe-bottom,0px))] z-[70] flex flex-col items-end gap-3 font-t-body">
<AnimatePresence>
{open && (
<motion.section
aria-label={`Chat mit ${name}`}
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.96 }}
transition={{ duration: 0.35, ease }}
className="flex h-[min(560px,calc(100dvh-7rem))] w-[min(380px,calc(100vw-2rem))] origin-bottom-right flex-col overflow-hidden rounded-3xl border border-t-fg/10 bg-t-bg text-t-fg shadow-[0_30px_80px_-20px_rgba(0,0,0,0.45)]"
>
<header className="flex items-center gap-3 border-b border-t-fg/10 px-4 py-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-t-accent font-semibold text-t-accent-fg">
{name.charAt(0).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{name}</p>
<p className="flex items-center gap-1.5 text-xs text-t-muted">
<span className="size-1.5 rounded-full bg-emerald-500" /> KI-Assistent · antwortet sofort
</p>
</div>
<button type="button" aria-label="Chat schließen" onClick={() => setOpen(false)} className="grid size-9 cursor-pointer place-items-center rounded-full hover:bg-t-fg/5">
<XIcon className="size-4" />
</button>
</header>
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">{greeting}</Bubble>
{chat.messages.map((message, i) => (
<div key={i} className="space-y-2">
{message.parts.map((part, j) =>
part.type === "booking" ? (
<BookingCard key={j} data={part.data} className="border-t-accent/40 bg-t-surface" />
) : (
<Bubble key={j} role={message.role}>{part.text}</Bubble>
)
)}
</div>
))}
{typing && (
<div className="flex w-fit gap-1 rounded-2xl rounded-bl-md bg-t-surface px-4 py-3">
{[0, 1, 2].map((dot) => (
<motion.span
key={dot}
className="size-1.5 rounded-full bg-t-muted"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.8, repeat: Infinity, delay: dot * 0.15 }}
/>
))}
</div>
)}
{chat.error && <p className="text-sm text-red-500">{chat.error}</p>}
{chat.messages.length === 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{QUICK_REPLIES.map((reply) => (
<button
key={reply}
type="button"
onClick={() => submit(reply)}
className="cursor-pointer rounded-full border border-t-fg/15 px-3 py-1.5 text-left text-xs transition-colors hover:border-t-accent hover:text-t-accent"
>
{reply}
</button>
))}
</div>
)}
</div>
<form
className="flex items-center gap-2 border-t border-t-fg/10 p-3"
onSubmit={(event) => {
event.preventDefault()
submit(draft)
}}
>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Nachricht schreiben …"
aria-label="Nachricht"
maxLength={1500}
className="min-w-0 flex-1 rounded-full bg-t-surface px-4 py-2.5 text-sm outline-none placeholder:text-t-muted focus:ring-2 focus:ring-t-accent/40"
/>
<button
type="submit"
aria-label="Senden"
disabled={!draft.trim() || chat.pending}
className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg transition-opacity disabled:opacity-40"
>
<ArrowUpIcon className="size-4" />
</button>
</form>
<p className="pb-2 text-center text-[10px] text-t-muted">KI-Demo · Antworten können Fehler enthalten</p>
</motion.section>
)}
</AnimatePresence>
<AnimatePresence>
{teaser && !open && (
<motion.button
type="button"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
onClick={() => setOpen(true)}
className="max-w-60 cursor-pointer rounded-2xl rounded-br-md bg-t-bg px-4 py-3 text-left text-sm text-t-fg shadow-xl ring-1 ring-t-fg/10"
>
Fragen? Ich antworte sofort – auch auf Terminwünsche.
</motion.button>
)}
</AnimatePresence>
<motion.button
type="button"
aria-label={open ? "Chat schließen" : `Chat mit ${name} öffnen`}
onClick={() => {
setOpen(!open)
setTeaser(false)
}}
whileHover={{ scale: 1.06 }}
whileTap={{ scale: 0.94 }}
className="grid size-14 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-[0_12px_40px_-8px_var(--t-accent)]"
>
{open ? <XIcon className="size-5" /> : <MessageCircleIcon className="size-6" />}
</motion.button>
</div>
)
}
function Bubble({ role, children }: { role: "user" | "assistant"; children: string }) {
return (
<p
className={cn(
"w-fit max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
role === "user" ? "ml-auto rounded-br-md bg-t-accent text-t-accent-fg" : "rounded-bl-md bg-t-surface"
)}
>
{children}
</p>
)
}