"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 = { 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("idle") const [heard, setHeard] = useState("") const [draft, setDraft] = useState("") const [seconds, setSeconds] = useState(0) const [micError, setMicError] = useState(null) const active = useRef(false) const voice = useRef(null) const recognition = useRef(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 (

{live ? "Verbunden" : "KI-Telefon"}

{[0, 1, 2].map((ring) => ( ))}
{info.brand.charAt(0).toUpperCase()}

{info.assistantName}

{info.brand}

{statusLabel[call]} {live && {time}}

{call === "listening" && [0, 1, 2, 3, 4].map((bar) => ( ))}
{live ? ( ) : ( )}

{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).")}

Live-Mitschrift
{call === "idle" && chat.messages.length === 0 && (

Hier erscheint das Gespräch, sobald Sie anrufen oder eine Frage tippen.

)} {call !== "idle" && } {chat.messages.map((message, i) => message.parts.map((part, j) => part.type === "booking" ? ( ) : ( ) ) )} {heard &&

„{heard} …“

} {chat.error &&

{chat.error}

}
{EXAMPLES.map((example) => ( ))}
{ event.preventDefault() sendText(draft) }} > 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" />
) } function Line({ role, text }: { role: "user" | "assistant"; text: string }) { return (

{role === "user" ? "Anrufer:in" : "Assistent"} {text}

) }