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

294 lines
13 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 { motion } from "motion/react"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { ArrowUpIcon, PhoneIcon, PhoneOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import { BookingCard } from "./booking-card"
import { useAiChat } from "./use-ai-chat"
// Web Speech API: nicht in allen TS-DOM-Typen enthalten, daher minimal selbst beschrieben
type RecognitionEvent = { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }
type Recognition = {
lang: string
interimResults: boolean
continuous: boolean
start(): void
abort(): void
onresult: ((event: RecognitionEvent) => void) | null
onerror: ((event: { error: string }) => void) | null
onend: (() => void) | null
}
type RecognitionCtor = new () => Recognition
function recognitionCtor(): RecognitionCtor | null {
if (typeof window === "undefined") return null
const speech = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor }
return speech.SpeechRecognition ?? speech.webkitSpeechRecognition ?? null
}
const subscribe = () => () => {}
const EXAMPLES = ["Haben Sie heute geöffnet?", "Ich möchte einen Termin vereinbaren", "Was kostet das bei Ihnen?"]
type CallState = "idle" | "speaking" | "listening" | "thinking" | "ended"
const statusLabel: Record<CallState, string> = {
idle: "Bereit für Ihren Anruf",
speaking: "Spricht …",
listening: "Hört zu …",
thinking: "Denkt nach …",
ended: "Anruf beendet",
}
export function PhoneDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [call, setCall] = useState<CallState>("idle")
const [heard, setHeard] = useState("")
const [draft, setDraft] = useState("")
const [seconds, setSeconds] = useState(0)
const [micError, setMicError] = useState<string | null>(null)
const active = useRef(false)
const voice = useRef<SpeechSynthesisVoice | null>(null)
const recognition = useRef<Recognition | null>(null)
const canListen = useSyncExternalStore(subscribe, () => recognitionCtor() !== null, () => true)
const chat = useAiChat({ access, mode: "phone", onReply: speak, onError: () => listen() })
const live = call !== "idle" && call !== "ended"
useEffect(() => {
const synth = typeof window !== "undefined" ? window.speechSynthesis : undefined
const pick = () => {
const german = (synth?.getVoices() ?? []).filter((v) => v.lang.toLowerCase().startsWith("de"))
voice.current = german.find((v) => /google|anna|petra|helena|vicki|marlene|katja/i.test(v.name)) ?? german[0] ?? null
}
pick()
synth?.addEventListener("voiceschanged", pick)
return () => {
synth?.removeEventListener("voiceschanged", pick)
active.current = false
recognition.current?.abort()
synth?.cancel()
}
}, [])
useEffect(() => {
if (!live) return
const timer = setInterval(() => setSeconds((s) => s + 1), 1000)
return () => clearInterval(timer)
}, [live])
function speak(text: string) {
if (!active.current) return
const synth = window.speechSynthesis
if (!text || !synth) return listen()
synth.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = "de-DE"
utterance.rate = 1.04
if (voice.current) utterance.voice = voice.current
utterance.onend = () => listen()
setCall("speaking")
synth.speak(utterance)
}
function listen() {
if (!active.current) return
setHeard("")
const Ctor = recognitionCtor()
if (!Ctor || micError) {
setCall("listening")
return
}
const rec = new Ctor()
rec.lang = "de-DE"
rec.interimResults = true
rec.continuous = false
let finalText = ""
let blocked = false
rec.onresult = (event) => {
let interim = ""
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) finalText += result[0].transcript
else interim += result[0].transcript
}
setHeard(`${finalText} ${interim}`.trim())
}
rec.onerror = (event) => {
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
blocked = true
setMicError("Kein Mikrofonzugriff – bitte erlauben oder unten tippen.")
}
}
rec.onend = () => {
recognition.current = null
if (!active.current) return
if (finalText.trim()) {
setHeard("")
setCall("thinking")
void chat.send(finalText)
} else if (blocked) {
setCall("listening")
} else {
listen()
}
}
recognition.current = rec
setCall("listening")
rec.start()
}
function start() {
active.current = true
setSeconds(0)
setMicError(null)
chat.reset()
speak(info.greeting)
}
function hangUp() {
active.current = false
recognition.current?.abort()
window.speechSynthesis?.cancel()
setCall("ended")
setHeard("")
}
function sendText(text: string) {
if (!text.trim() || chat.pending) return
if (!active.current) {
active.current = true
setSeconds(0)
}
recognition.current?.abort()
setDraft("")
setCall("thinking")
void chat.send(text)
}
const time = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
<section className="flex flex-col items-center rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center">
<p className="text-xs uppercase tracking-[0.25em] text-zinc-400">{live ? "Verbunden" : "KI-Telefon"}</p>
<div className="relative mt-10 grid size-52 place-items-center">
{[0, 1, 2].map((ring) => (
<motion.span
key={ring}
className="absolute inset-0 rounded-full border border-t-accent"
animate={call === "speaking" ? { scale: [1, 1.45], opacity: [0.5, 0] } : { scale: 1, opacity: live ? 0.25 : 0.1 }}
transition={call === "speaking" ? { duration: 1.8, repeat: Infinity, delay: ring * 0.6, ease: "easeOut" } : { duration: 0.4 }}
/>
))}
<div className="relative grid size-36 place-items-center rounded-full bg-t-accent text-5xl font-semibold text-t-accent-fg shadow-[0_0_90px_-10px_var(--t-accent)]">
{info.brand.charAt(0).toUpperCase()}
</div>
</div>
<p className="mt-8 text-xl font-semibold">{info.assistantName}</p>
<p className="text-sm text-zinc-400">{info.brand}</p>
<p className="mt-4 flex h-6 items-center gap-2 text-sm text-t-accent">
{statusLabel[call]}
{live && <span className="font-mono text-zinc-400">{time}</span>}
</p>
<div className="mt-3 flex h-8 items-end gap-1" aria-hidden>
{call === "listening" &&
[0, 1, 2, 3, 4].map((bar) => (
<motion.span
key={bar}
className="w-1.5 rounded-full bg-t-accent"
animate={{ height: [8, 26, 12, 30, 8] }}
transition={{ duration: 1.1, repeat: Infinity, delay: bar * 0.12 }}
/>
))}
</div>
<div className="mt-6">
{live ? (
<button type="button" onClick={hangUp} aria-label="Auflegen" className="grid size-16 cursor-pointer place-items-center rounded-full bg-red-500 text-white transition-transform hover:scale-105">
<PhoneOffIcon className="size-6" />
</button>
) : (
<button type="button" onClick={start} aria-label="Anrufen" className="relative grid size-16 cursor-pointer place-items-center rounded-full bg-emerald-500 text-white transition-transform hover:scale-105">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/40" />
<PhoneIcon className="relative size-6" />
</button>
)}
</div>
<p className="mt-6 max-w-xs text-xs text-zinc-500">
{micError ??
(canListen
? "Nach dem Anrufen den Mikrofonzugriff erlauben und einfach sprechen. Ton einschalten!"
: "Dieser Browser kann keine Sprache erkennen – tippen Sie rechts (Chrome, Edge oder Safari empfohlen).")}
</p>
</section>
<section className="flex min-h-[32rem] flex-col rounded-[2rem] border border-white/10 bg-white/[0.03]">
<header className="border-b border-white/10 px-6 py-4 text-sm font-medium">Live-Mitschrift</header>
<div className="flex-1 space-y-3 overflow-y-auto px-6 py-5">
{call === "idle" && chat.messages.length === 0 && (
<p className="text-sm text-zinc-500">Hier erscheint das Gespräch, sobald Sie anrufen oder eine Frage tippen.</p>
)}
{call !== "idle" && <Line role="assistant" text={info.greeting} />}
{chat.messages.map((message, i) =>
message.parts.map((part, j) =>
part.type === "booking" ? (
<BookingCard key={`${i}-${j}`} data={part.data} className="border-t-accent/40 bg-white/5" />
) : (
<Line key={`${i}-${j}`} role={message.role} text={part.text} />
)
)
)}
{heard && <p className="text-sm italic text-zinc-400">„{heard} …“</p>}
{chat.error && <p className="text-sm text-red-400">{chat.error}</p>}
</div>
<div className="space-y-3 border-t border-white/10 p-4">
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => sendText(example)}
className="cursor-pointer rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:border-t-accent hover:text-white"
>
{example}
</button>
))}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
sendText(draft)
}}
>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Oder hier tippen …"
aria-label="Nachricht an den Assistenten"
className="min-w-0 flex-1 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm outline-none focus:border-t-accent"
/>
<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 disabled:opacity-40">
<ArrowUpIcon className="size-4" />
</button>
</form>
</div>
</section>
</div>
)
}
function Line({ role, text }: { role: "user" | "assistant"; text: string }) {
return (
<div className={cn("flex", role === "user" && "justify-end")}>
<p className={cn("max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed", role === "user" ? "bg-t-accent text-t-accent-fg" : "bg-white/[0.06]")}>
<span className="mb-0.5 block text-[10px] uppercase tracking-wider opacity-60">{role === "user" ? "Anrufer:in" : "Assistent"}</span>
{text}
</p>
</div>
)
}