mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
192 lines
9.8 KiB
TypeScript
192 lines
9.8 KiB
TypeScript
"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>
|
||
)
|
||
}
|