diff --git a/src/app/[slug]/[configId]/layout.tsx b/src/app/[slug]/[configId]/layout.tsx index bfec46e..8ac4d51 100644 --- a/src/app/[slug]/[configId]/layout.tsx +++ b/src/app/[slug]/[configId]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation' import { getInstanceBySlug, getConfiguration } from '@/lib/api/client' import { resolveColors } from '@/lib/theme' import SplashScreen from '@/components/SplashScreen' +import AssistantBubble from '@/components/assistant/AssistantBubble' export default async function ConfigLayout({ children, @@ -27,6 +28,16 @@ export default async function ConfigLayout({
{loaderImageUrl && } {children} + {instance.isAssistant && instance.publicApiKey && ( + + )}
) } diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index 57dee21..b989196 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation' import { getInstanceBySlug } from '@/lib/api/client' import { resolveColors } from '@/lib/theme' import { VisitorProvider } from '@/context/VisitorContext' +import TrialWatermark from '@/components/ui/TrialWatermark' export default async function SlugLayout({ children, @@ -25,6 +26,7 @@ export default async function SlugLayout({
{children} + {instance.isTrialActive && }
) diff --git a/src/components/assistant/AssistantBubble.tsx b/src/components/assistant/AssistantBubble.tsx new file mode 100644 index 0000000..f082719 --- /dev/null +++ b/src/components/assistant/AssistantBubble.tsx @@ -0,0 +1,412 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { useVisitor } from '@/context/VisitorContext' +import { sendAssistantMessage, AssistantUnavailableError } from '@/lib/api/client' +import { buildSuggestions } from '@/lib/assistantSuggestions' +import { stripHtml } from '@/lib/i18n' +import type { AiChatMessageDTO, AiChatResponseDTO, SectionDTO } from '@/lib/api/types' +import './assistant.css' + +type Turn = + | { from: 'visitor'; text: string } + | { from: 'guide'; response: AiChatResponseDTO } + | { from: 'guide'; error: 'failed' | 'unavailable' } + +interface Props { + instanceId: string + apiKey: string + configId: string + slug: string + sections?: SectionDTO[] + /// Nom du lieu, affiché sous le titre — le visiteur sait à qui il parle. + venueName?: string +} + +const UI: Record> = { + title: { FR: 'Votre guide', NL: 'Uw gids', EN: 'Your guide', DE: 'Ihr Guide', ES: 'Su guía', IT: 'La tua guida' }, + open: { FR: "Ouvrir l'assistant", NL: 'Assistent openen', EN: 'Open the assistant', DE: 'Assistent öffnen', ES: 'Abrir el asistente', IT: 'Apri l’assistente' }, + close: { FR: "Fermer l'assistant", NL: 'Assistent sluiten', EN: 'Close the assistant', DE: 'Assistent schließen', ES: 'Cerrar el asistente', IT: 'Chiudi l’assistente' }, + hint: { FR: 'Une question ?', NL: 'Een vraag?', EN: 'A question?', DE: 'Eine Frage?', ES: '¿Una pregunta?', IT: 'Una domanda?' }, + welcome: { + FR: 'Bonjour ! Je connais ce lieu par cœur. Que puis-je vous montrer ?', + NL: 'Hallo! Ik ken deze plek van binnen en van buiten. Wat kan ik u tonen?', + EN: 'Hello! I know this place inside out. What can I show you?', + DE: 'Hallo! Ich kenne diesen Ort in- und auswendig. Was darf ich Ihnen zeigen?', + ES: '¡Hola! Conozco este lugar a fondo. ¿Qué puedo enseñarle?', + IT: 'Salve! Conosco questo luogo a memoria. Cosa posso mostrarle?', + }, + placeholder: { FR: 'Posez votre question…', NL: 'Stel uw vraag…', EN: 'Ask your question…', DE: 'Stellen Sie Ihre Frage…', ES: 'Haga su pregunta…', IT: 'Fai la tua domanda…' }, + send: { FR: 'Envoyer', NL: 'Verzenden', EN: 'Send', DE: 'Senden', ES: 'Enviar', IT: 'Invia' }, + dictate: { FR: 'Dicter la question', NL: 'Vraag dicteren', EN: 'Dictate your question', DE: 'Frage diktieren', ES: 'Dictar la pregunta', IT: 'Detta la domanda' }, + listening: { FR: 'Je vous écoute…', NL: 'Ik luister…', EN: 'Listening…', DE: 'Ich höre zu…', ES: 'Le escucho…', IT: 'Ti ascolto…' }, + writing: { FR: 'Le guide rédige', NL: 'De gids schrijft', EN: 'The guide is writing', DE: 'Der Guide schreibt', ES: 'La guía está escribiendo', IT: 'La guida sta scrivendo' }, + openSection: { FR: 'Ouvrir cette section', NL: 'Open dit onderdeel', EN: 'Open this section', DE: 'Diesen Bereich öffnen', ES: 'Abrir esta sección', IT: 'Apri questa sezione' }, + failed: { FR: "La réponse n'est pas arrivée.", NL: 'Het antwoord is niet aangekomen.', EN: "The answer didn't come through.", DE: 'Die Antwort kam nicht an.', ES: 'La respuesta no ha llegado.', IT: 'La risposta non è arrivata.' }, + retry: { FR: 'Réessayer', NL: 'Opnieuw', EN: 'Try again', DE: 'Erneut versuchen', ES: 'Reintentar', IT: 'Riprova' }, + unavailable: { + FR: "Le guide se repose pour aujourd'hui. Revenez demain.", + NL: 'De gids rust vandaag uit. Kom morgen terug.', + EN: 'The guide is resting for today. Come back tomorrow.', + DE: 'Der Guide ruht sich heute aus. Kommen Sie morgen wieder.', + ES: 'La guía descansa por hoy. Vuelva mañana.', + IT: 'La guida riposa per oggi. Torna domani.', + }, +} + +const label = (key: keyof typeof UI, lang: string) => UI[key][lang] ?? UI[key].FR + +/// L'API de reconnaissance vocale n'est pas standardisée partout : on ne montre le +/// micro que si le navigateur la propose réellement. +function getSpeechRecognition(): any { + if (typeof window === 'undefined') return null + return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null +} + +export default function AssistantBubble({ + instanceId, apiKey, configId, slug, sections, venueName, +}: Props) { + const { language } = useVisitor() + const router = useRouter() + const pathname = usePathname() + + const [open, setOpen] = useState(false) + const [turns, setTurns] = useState([]) + const [draft, setDraft] = useState('') + const [pending, setPending] = useState(false) + const [listening, setListening] = useState(false) + const [speechReady, setSpeechReady] = useState(false) + + const threadRef = useRef(null) + const inputRef = useRef(null) + const launcherRef = useRef(null) + const recognitionRef = useRef(null) + const lastQuestionRef = useRef('') + + // La section ouverte se lit dans l'URL — inutile de la faire descendre depuis + // le layout, qui ne la connaît pas. + const currentSection = useMemo(() => { + const match = pathname?.match(/\/sections\/([^/]+)/) + if (!match) return null + return sections?.find((s) => s.id === match[1]) ?? null + }, [pathname, sections]) + + const suggestions = useMemo( + () => buildSuggestions(sections, language, currentSection), + [sections, language, currentSection] + ) + + useEffect(() => { setSpeechReady(!!getSpeechRecognition()) }, []) + + useEffect(() => { + const el = threadRef.current + if (el) el.scrollTop = el.scrollHeight + }, [turns, pending]) + + useEffect(() => { + if (open) inputRef.current?.focus() + }, [open]) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + setOpen(false) + launcherRef.current?.focus() + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open]) + + // Historique envoyé au modèle : seulement les tours aboutis. + function historyFrom(list: Turn[]): AiChatMessageDTO[] { + return list.flatMap((turn) => { + if (turn.from === 'visitor') return [{ role: 'user', content: turn.text }] + if ('response' in turn) return [{ role: 'assistant', content: stripHtml(turn.response.reply) }] + return [] + }) + } + + async function ask(question: string) { + const text = question.trim() + if (!text || pending) return + + lastQuestionRef.current = text + setDraft('') + const withQuestion: Turn[] = [...turns, { from: 'visitor', text }] + setTurns(withQuestion) + setPending(true) + + try { + const response = await sendAssistantMessage( + { + message: text, + instanceId, + configurationId: configId, + language, + history: historyFrom(turns), + }, + apiKey + ) + setTurns([...withQuestion, { from: 'guide', response }]) + + // `expectsReply: false` = le modèle clôt l'échange (politesse, info pure). + // On ne réarme alors pas la dictée — c'est sa raison d'être côté mains-libres. + if (listening && response.expectsReply === false) stopListening() + } catch (err) { + const kind = err instanceof AssistantUnavailableError ? 'unavailable' : 'failed' + setTurns([...withQuestion, { from: 'guide', error: kind }]) + } finally { + setPending(false) + inputRef.current?.focus() + } + } + + function stopListening() { + recognitionRef.current?.stop() + recognitionRef.current = null + setListening(false) + } + + function toggleListening() { + if (listening) return stopListening() + + const Recognition = getSpeechRecognition() + if (!Recognition) return + + const recognition = new Recognition() + recognition.lang = { FR: 'fr-FR', NL: 'nl-NL', EN: 'en-US', DE: 'de-DE', ES: 'es-ES', IT: 'it-IT' }[language] ?? 'fr-FR' + recognition.interimResults = true + recognition.continuous = false + + recognition.onresult = (event: any) => { + const transcript = Array.from(event.results).map((r: any) => r[0].transcript).join('') + setDraft(transcript) + if (event.results[event.results.length - 1].isFinal) { + stopListening() + ask(transcript) + } + } + recognition.onerror = () => stopListening() + recognition.onend = () => setListening(false) + + recognitionRef.current = recognition + setListening(true) + recognition.start() + } + + function openSection(sectionId: string) { + setOpen(false) + router.push(`/${slug}/${configId}/sections/${sectionId}`) + } + + return ( + <> + {!open && ( + + )} + + + +