Assistant IA + watermark d'essai + parcours guidés + fix du build
Assistant IA (débloque l'add-on sur le plan Essentiel, qui est web-only) components/assistant/ (AssistantBubble.tsx + assistant.css), lib/assistantSuggestions.ts + test. Suggestions dérivées du contenu réel de l'instance, zéro configuration côté client. Onboarding TrialWatermark.tsx — bandeau « Aperçu » pendant l'essai. Parcours & carte ParcoursSection.tsx (+108), LeafletMap.tsx, map.css, MapSection.tsx : centrage de carte et icônes corrigés (M1, M2, W2 — des champs que le client saisissait sans qu'ils aient le moindre effet). ResourceViewer.tsx : rendu des ressources d'étape. API client.ts (+44) et types.ts (+41) alignés sur les nouveaux champs backend. Fix du build lib/geo.ts — helper getGeoPointLatLng(). Les 7 erreurs venaient d'un unknown non narrowé. ⚠️ Ce type d'erreur est INVISIBLE en next dev : seul npm run build le voit. npm run build ✅. Reste ouvert : W1 — LeafletMap a toujours un TileLayer en dur, un client qui choisit « Google Hybrid » voit de l'OSM.
This commit is contained in:
parent
2af78bbe7a
commit
473fc3acab
@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
|
|||||||
import { getInstanceBySlug, getConfiguration } from '@/lib/api/client'
|
import { getInstanceBySlug, getConfiguration } from '@/lib/api/client'
|
||||||
import { resolveColors } from '@/lib/theme'
|
import { resolveColors } from '@/lib/theme'
|
||||||
import SplashScreen from '@/components/SplashScreen'
|
import SplashScreen from '@/components/SplashScreen'
|
||||||
|
import AssistantBubble from '@/components/assistant/AssistantBubble'
|
||||||
|
|
||||||
export default async function ConfigLayout({
|
export default async function ConfigLayout({
|
||||||
children,
|
children,
|
||||||
@ -27,6 +28,16 @@ export default async function ConfigLayout({
|
|||||||
<div style={theme as React.CSSProperties}>
|
<div style={theme as React.CSSProperties}>
|
||||||
{loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />}
|
{loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />}
|
||||||
{children}
|
{children}
|
||||||
|
{instance.isAssistant && instance.publicApiKey && (
|
||||||
|
<AssistantBubble
|
||||||
|
instanceId={instance.id}
|
||||||
|
apiKey={instance.publicApiKey}
|
||||||
|
configId={configId}
|
||||||
|
slug={slug}
|
||||||
|
sections={config.sections}
|
||||||
|
venueName={instance.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
|
|||||||
import { getInstanceBySlug } from '@/lib/api/client'
|
import { getInstanceBySlug } from '@/lib/api/client'
|
||||||
import { resolveColors } from '@/lib/theme'
|
import { resolveColors } from '@/lib/theme'
|
||||||
import { VisitorProvider } from '@/context/VisitorContext'
|
import { VisitorProvider } from '@/context/VisitorContext'
|
||||||
|
import TrialWatermark from '@/components/ui/TrialWatermark'
|
||||||
|
|
||||||
export default async function SlugLayout({
|
export default async function SlugLayout({
|
||||||
children,
|
children,
|
||||||
@ -25,6 +26,7 @@ export default async function SlugLayout({
|
|||||||
<VisitorProvider instanceId={instance.id}>
|
<VisitorProvider instanceId={instance.id}>
|
||||||
<div style={theme as React.CSSProperties}>
|
<div style={theme as React.CSSProperties}>
|
||||||
{children}
|
{children}
|
||||||
|
{instance.isTrialActive && <TrialWatermark />}
|
||||||
</div>
|
</div>
|
||||||
</VisitorProvider>
|
</VisitorProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
412
src/components/assistant/AssistantBubble.tsx
Normal file
412
src/components/assistant/AssistantBubble.tsx
Normal file
@ -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<string, Record<string, string>> = {
|
||||||
|
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<Turn[]>([])
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [listening, setListening] = useState(false)
|
||||||
|
const [speechReady, setSpeechReady] = useState(false)
|
||||||
|
|
||||||
|
const threadRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const launcherRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const recognitionRef = useRef<any>(null)
|
||||||
|
const lastQuestionRef = useRef<string>('')
|
||||||
|
|
||||||
|
// 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<AiChatMessageDTO>((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 && (
|
||||||
|
<span className="mim-assistant-hint" aria-hidden="true">{label('hint', language)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={launcherRef}
|
||||||
|
type="button"
|
||||||
|
className="mim-assistant-launcher"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="mim-assistant-panel"
|
||||||
|
aria-label={label(open ? 'close' : 'open', language)}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
{open ? <IconClose /> : <IconChat />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="mim-assistant-panel"
|
||||||
|
className="mim-assistant-panel"
|
||||||
|
data-open={open}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={label('title', language)}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<header className="mim-assistant-head">
|
||||||
|
<span className="mim-assistant-avatar" aria-hidden="true"><IconChat /></span>
|
||||||
|
<span className="mim-assistant-identity">
|
||||||
|
<strong>{label('title', language)}</strong>
|
||||||
|
{venueName && <span>{venueName}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mim-assistant-icon-btn"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label={label('close', language)}
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mim-assistant-thread" ref={threadRef} aria-live="polite">
|
||||||
|
{turns.length === 0 && (
|
||||||
|
<div className="mim-assistant-opener">
|
||||||
|
<span className="mim-assistant-mark" aria-hidden="true"><IconChat /></span>
|
||||||
|
<p>{label('welcome', language)}</p>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mim-assistant-chips">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button key={s} type="button" className="mim-assistant-chip" onClick={() => ask(s)}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{turns.map((turn, i) =>
|
||||||
|
turn.from === 'visitor' ? (
|
||||||
|
<div key={i} className="mim-msg mim-msg-visitor">
|
||||||
|
<div className="mim-bubble">{turn.text}</div>
|
||||||
|
</div>
|
||||||
|
) : 'error' in turn ? (
|
||||||
|
<div key={i} className={`mim-banner mim-banner-${turn.error === 'unavailable' ? 'notice' : 'error'}`}>
|
||||||
|
<span>
|
||||||
|
{label(turn.error === 'unavailable' ? 'unavailable' : 'failed', language)}
|
||||||
|
{turn.error === 'failed' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mim-assistant-chip mim-retry"
|
||||||
|
onClick={() => ask(lastQuestionRef.current)}
|
||||||
|
>
|
||||||
|
{label('retry', language)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="mim-msg mim-msg-guide">
|
||||||
|
{turn.response.reply && (
|
||||||
|
<div
|
||||||
|
className="mim-bubble"
|
||||||
|
// Les réponses viennent de l'éditeur riche, comme le reste des contenus.
|
||||||
|
dangerouslySetInnerHTML={{ __html: turn.response.reply }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!turn.response.cards?.length && (
|
||||||
|
<div className="mim-cards">
|
||||||
|
{turn.response.cards.map((card, j) => (
|
||||||
|
<div key={j} className="mim-card">
|
||||||
|
{card.icon && <span className="mim-card-icon" aria-hidden="true">{card.icon}</span>}
|
||||||
|
<span className="mim-card-text">
|
||||||
|
<strong>{card.title}</strong>
|
||||||
|
{card.subtitle && <span>{card.subtitle}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{turn.response.navigation && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mim-nav-action"
|
||||||
|
onClick={() => openSection(turn.response.navigation!.sectionId)}
|
||||||
|
>
|
||||||
|
<span className="mim-nav-thumb" aria-hidden="true">
|
||||||
|
{turn.response.navigation.imageUrl
|
||||||
|
? <img src={turn.response.navigation.imageUrl} alt="" />
|
||||||
|
: <IconPin />}
|
||||||
|
</span>
|
||||||
|
<span className="mim-nav-text">
|
||||||
|
<strong>{stripHtml(turn.response.navigation.sectionTitle)}</strong>
|
||||||
|
<span>{label('openSection', language)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mim-nav-chevron" aria-hidden="true"><IconChevron /></span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div className="mim-typing" aria-label={label('writing', language)}>
|
||||||
|
<i /><i /><i />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listening && (
|
||||||
|
<div className="mim-listening">
|
||||||
|
<IconMic />
|
||||||
|
<span>{label('listening', language)}</span>
|
||||||
|
<span className="mim-wave" aria-hidden="true">
|
||||||
|
<i /><i /><i /><i /><i />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mim-assistant-composer"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); ask(draft) }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="mim-assistant-field"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder={listening ? label('listening', language) : label('placeholder', language)}
|
||||||
|
aria-label={label('placeholder', language)}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
{speechReady && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mim-round-btn mim-mic"
|
||||||
|
data-listening={listening}
|
||||||
|
onClick={toggleListening}
|
||||||
|
aria-label={label('dictate', language)}
|
||||||
|
aria-pressed={listening}
|
||||||
|
>
|
||||||
|
<IconMic />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mim-round-btn mim-send"
|
||||||
|
aria-label={label('send', language)}
|
||||||
|
disabled={pending || !draft.trim()}
|
||||||
|
>
|
||||||
|
<IconSend />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Icônes ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
|
||||||
|
|
||||||
|
const IconChat = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const IconClose = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.1} aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||||
|
)
|
||||||
|
const IconPin = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const IconChevron = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.2} aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
|
)
|
||||||
|
const IconMic = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true">
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const IconSend = () => (
|
||||||
|
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4z" /></svg>
|
||||||
|
)
|
||||||
387
src/components/assistant/assistant.css
Normal file
387
src/components/assistant/assistant.css
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
/* Bulle assistant — flotte au-dessus de la page sans rien pousser.
|
||||||
|
Toutes les couleurs viennent des variables d'instance injectées par le layout. */
|
||||||
|
|
||||||
|
.mim-assistant-launcher {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 1200;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 26px -8px color-mix(in srgb, var(--color-primary) 65%, transparent),
|
||||||
|
0 1px 3px rgba(0, 0, 0, .18);
|
||||||
|
transition: transform .22s cubic-bezier(.2, .7, .3, 1);
|
||||||
|
}
|
||||||
|
.mim-assistant-launcher:hover { transform: translateY(-2px) scale(1.04); }
|
||||||
|
.mim-assistant-launcher svg { width: 23px; height: 23px; }
|
||||||
|
|
||||||
|
.mim-assistant-hint {
|
||||||
|
position: fixed;
|
||||||
|
right: 86px;
|
||||||
|
bottom: 34px;
|
||||||
|
z-index: 1200;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .38rem .78rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mim-assistant-panel {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 88px;
|
||||||
|
z-index: 1199;
|
||||||
|
width: 384px;
|
||||||
|
height: min(480px, calc(100vh - 130px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, .5), 0 4px 18px rgba(0, 0, 0, .08);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition: opacity .26s ease, transform .26s cubic-bezier(.2, .7, .3, 1), visibility .26s;
|
||||||
|
}
|
||||||
|
.mim-assistant-panel[data-open='false'] {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(14px) scale(.94);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.mim-assistant-panel {
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 84px;
|
||||||
|
width: auto;
|
||||||
|
height: calc(100dvh - 104px);
|
||||||
|
}
|
||||||
|
.mim-assistant-hint { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-assistant-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.mim-assistant-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.mim-assistant-avatar svg { width: 17px; height: 17px; }
|
||||||
|
.mim-assistant-identity { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
|
||||||
|
.mim-assistant-identity strong { font-size: .92rem; font-weight: 650; color: var(--color-text); }
|
||||||
|
.mim-assistant-identity span {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mim-assistant-icon-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.mim-assistant-icon-btn:hover { background: var(--color-background); color: var(--color-text); }
|
||||||
|
.mim-assistant-icon-btn svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* ── Fil ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-assistant-thread {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .7rem;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 40%, transparent) transparent;
|
||||||
|
}
|
||||||
|
.mim-assistant-thread::-webkit-scrollbar { width: 10px; }
|
||||||
|
.mim-assistant-thread::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.mim-assistant-thread::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--color-text-muted) 38%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
.mim-assistant-thread:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--color-text-muted) 62%, transparent);
|
||||||
|
}
|
||||||
|
.mim-assistant-thread::-webkit-scrollbar-button { display: none; }
|
||||||
|
|
||||||
|
.mim-msg { display: flex; flex-direction: column; gap: .45rem; max-width: 86%; }
|
||||||
|
.mim-msg-visitor { align-self: flex-end; align-items: flex-end; }
|
||||||
|
.mim-msg-guide { align-self: flex-start; }
|
||||||
|
|
||||||
|
.mim-bubble {
|
||||||
|
padding: .6rem .85rem;
|
||||||
|
font-size: .89rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.mim-msg-visitor .mim-bubble {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.mim-msg-guide .mim-bubble {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.mim-bubble p { margin: 0; }
|
||||||
|
.mim-bubble p + p { margin-top: .5rem; }
|
||||||
|
.mim-bubble a { color: var(--color-primary); }
|
||||||
|
|
||||||
|
/* ── Cards et navigation ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-cards { display: flex; flex-direction: column; gap: .4rem; width: 100%; }
|
||||||
|
.mim-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .55rem .7rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.mim-card-icon { font-size: 1.05rem; line-height: 1.3; flex: none; }
|
||||||
|
.mim-card-text { min-width: 0; }
|
||||||
|
.mim-card-text strong { display: block; font-size: .82rem; font-weight: 650; color: var(--color-text); }
|
||||||
|
.mim-card-text span { display: block; font-size: .76rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.mim-nav-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .65rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
padding: .55rem .65rem;
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 32%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color .18s ease, transform .18s ease;
|
||||||
|
}
|
||||||
|
.mim-nav-action:hover { border-color: var(--color-primary); transform: translateX(2px); }
|
||||||
|
.mim-nav-thumb {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mim-nav-thumb svg { width: 18px; height: 18px; }
|
||||||
|
.mim-nav-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.mim-nav-text { min-width: 0; flex: 1; }
|
||||||
|
.mim-nav-text strong { display: block; font-size: .82rem; font-weight: 650; color: var(--color-text); }
|
||||||
|
.mim-nav-text span { display: block; font-size: .72rem; color: var(--color-text-muted); }
|
||||||
|
.mim-nav-chevron { color: var(--color-primary); flex: none; }
|
||||||
|
.mim-nav-chevron svg { width: 16px; height: 16px; display: block; }
|
||||||
|
|
||||||
|
/* ── Accueil ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-assistant-opener {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .8rem;
|
||||||
|
padding: 1.5rem .4rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
.mim-assistant-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.mim-assistant-mark svg { width: 21px; height: 21px; }
|
||||||
|
.mim-assistant-opener p { margin: 0; font-size: .88rem; color: var(--color-text-muted); text-wrap: pretty; }
|
||||||
|
|
||||||
|
.mim-assistant-chips { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center; }
|
||||||
|
.mim-assistant-chip {
|
||||||
|
font: inherit;
|
||||||
|
font-size: .78rem;
|
||||||
|
padding: .35rem .7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .18s ease, color .18s ease;
|
||||||
|
}
|
||||||
|
.mim-assistant-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
.mim-retry { margin-left: .4rem; padding: .1rem .5rem; }
|
||||||
|
|
||||||
|
/* ── Rédaction, erreurs ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-typing {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .7rem .85rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: 14px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.mim-typing i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
animation: mim-pulse 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mim-typing i:nth-child(2) { animation-delay: .18s; }
|
||||||
|
.mim-typing i:nth-child(3) { animation-delay: .36s; }
|
||||||
|
@keyframes mim-pulse {
|
||||||
|
0%, 60%, 100% { opacity: .3; transform: translateY(0); }
|
||||||
|
30% { opacity: 1; transform: translateY(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Écoute en cours — le micro seul ne suffit pas à dire que ça enregistre. */
|
||||||
|
.mim-listening {
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
padding: .55rem .8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(180, 67, 47, .1);
|
||||||
|
color: #B4432F;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mim-listening svg { width: 15px; height: 15px; flex: none; }
|
||||||
|
.mim-wave { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
|
.mim-wave i {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: mim-bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mim-wave i:nth-child(1) { height: 8px; }
|
||||||
|
.mim-wave i:nth-child(2) { height: 14px; animation-delay: .12s; }
|
||||||
|
.mim-wave i:nth-child(3) { height: 20px; animation-delay: .24s; }
|
||||||
|
.mim-wave i:nth-child(4) { height: 11px; animation-delay: .36s; }
|
||||||
|
.mim-wave i:nth-child(5) { height: 16px; animation-delay: .48s; }
|
||||||
|
@keyframes mim-bounce {
|
||||||
|
0%, 100% { transform: scaleY(.5); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrivée des messages */
|
||||||
|
.mim-msg, .mim-banner {
|
||||||
|
animation: mim-rise .38s cubic-bezier(.2, .7, .3, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes mim-rise {
|
||||||
|
from { opacity: 0; transform: translateY(9px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mim-banner {
|
||||||
|
padding: .65rem .8rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: .82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 92%;
|
||||||
|
}
|
||||||
|
.mim-banner-error { background: rgba(180, 67, 47, .1); color: #B4432F; }
|
||||||
|
.mim-banner-notice { background: rgba(184, 134, 59, .12); color: #8A6320; }
|
||||||
|
|
||||||
|
/* ── Saisie ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mim-assistant-composer {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .7rem 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.mim-assistant-field {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .55rem .9rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: .86rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mim-assistant-field::placeholder { color: var(--color-text-muted); }
|
||||||
|
.mim-assistant-field:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.mim-round-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.mim-round-btn svg { width: 15px; height: 15px; }
|
||||||
|
.mim-round-btn:disabled { opacity: .45; cursor: default; }
|
||||||
|
.mim-mic { background: var(--color-background); color: var(--color-text-muted); }
|
||||||
|
.mim-mic[data-listening='true'] { background: #B4432F; color: #fff; }
|
||||||
|
.mim-send { background: var(--color-primary); color: var(--color-on-primary); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mim-assistant-panel,
|
||||||
|
.mim-assistant-launcher,
|
||||||
|
.mim-nav-action { transition: none; }
|
||||||
|
.mim-typing i, .mim-wave i { animation: none; opacity: .6; }
|
||||||
|
.mim-msg, .mim-banner { animation: none; }
|
||||||
|
.mim-assistant-thread { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import { useVisitor } from '@/context/VisitorContext'
|
|||||||
import { t, tPlain } from '@/lib/i18n'
|
import { t, tPlain } from '@/lib/i18n'
|
||||||
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
||||||
import { trackEvent } from '@/lib/stats'
|
import { trackEvent } from '@/lib/stats'
|
||||||
|
import { getGeoPointLatLng } from '@/lib/geo'
|
||||||
import './map/map.css'
|
import './map/map.css'
|
||||||
|
|
||||||
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
|
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
|
||||||
@ -62,9 +63,9 @@ export default function MapSection({ section, configId, languages }: Props) {
|
|||||||
const lat = parseFloat(map?.centerLatitude ?? '')
|
const lat = parseFloat(map?.centerLatitude ?? '')
|
||||||
const lng = parseFloat(map?.centerLongitude ?? '')
|
const lng = parseFloat(map?.centerLongitude ?? '')
|
||||||
if (!isNaN(lat) && !isNaN(lng)) return [lat, lng]
|
if (!isNaN(lat) && !isNaN(lng)) return [lat, lng]
|
||||||
const first = points.find((p) => p.geometry?.coordinates)
|
for (const p of points) {
|
||||||
if (first?.geometry?.coordinates) {
|
const position = getGeoPointLatLng(p.geometry)
|
||||||
return [first.geometry.coordinates[1], first.geometry.coordinates[0]]
|
if (position) return position
|
||||||
}
|
}
|
||||||
return [50.5, 4.5]
|
return [50.5, 4.5]
|
||||||
}, [map, points])
|
}, [map, points])
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useBack } from '@/hooks/useBack'
|
import { useBack } from '@/hooks/useBack'
|
||||||
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
||||||
|
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
|
||||||
import { useVisitor } from '@/context/VisitorContext'
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
import { t, tPlain } from '@/lib/i18n'
|
import { t, tPlain } from '@/lib/i18n'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
@ -45,6 +46,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
const [view, setView] = useState<View>('list')
|
const [view, setView] = useState<View>('list')
|
||||||
const [activePath, setActivePath] = useState<GuidedPathDTO | null>(null)
|
const [activePath, setActivePath] = useState<GuidedPathDTO | null>(null)
|
||||||
const [stepIndex, setStepIndex] = useState(0)
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
|
const [maxReached, setMaxReached] = useState(0)
|
||||||
const isGame = activePath?.isGameMode === true
|
const isGame = activePath?.isGameMode === true
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set())
|
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set())
|
||||||
const [primaryColor, setPrimaryColor] = useState('#264863')
|
const [primaryColor, setPrimaryColor] = useState('#264863')
|
||||||
@ -62,9 +64,25 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
|
|
||||||
const currentStep = steps[stepIndex] ?? null
|
const currentStep = steps[stepIndex] ?? null
|
||||||
|
|
||||||
|
// `isLinear = false` : le visiteur choisit ses étapes dans l'ordre qu'il veut.
|
||||||
|
const isFreeNavigation = activePath?.isLinear === false
|
||||||
|
|
||||||
|
// En navigation libre on atteint n'importe quelle étape ; sinon seulement celles
|
||||||
|
// déjà parcourues (relire une étape vue n'est pas de la triche).
|
||||||
|
function canJumpTo(i: number) {
|
||||||
|
return i >= 0 && i < steps.length && (isFreeNavigation || i <= maxReached)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToStep(i: number) {
|
||||||
|
if (i === stepIndex || !canJumpTo(i)) return
|
||||||
|
setStepIndex(i)
|
||||||
|
setMaxReached((m) => Math.max(m, i))
|
||||||
|
}
|
||||||
|
|
||||||
function selectPath(path: GuidedPathDTO) {
|
function selectPath(path: GuidedPathDTO) {
|
||||||
setActivePath(path)
|
setActivePath(path)
|
||||||
setStepIndex(0)
|
setStepIndex(0)
|
||||||
|
setMaxReached(0)
|
||||||
setCompletedSteps(new Set())
|
setCompletedSteps(new Set())
|
||||||
setView('start')
|
setView('start')
|
||||||
}
|
}
|
||||||
@ -77,6 +95,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
if (currentStep) setCompletedSteps((s) => new Set(s).add(currentStep.id))
|
if (currentStep) setCompletedSteps((s) => new Set(s).add(currentStep.id))
|
||||||
if (stepIndex < steps.length - 1) {
|
if (stepIndex < steps.length - 1) {
|
||||||
setStepIndex((i) => i + 1)
|
setStepIndex((i) => i + 1)
|
||||||
|
setMaxReached((m) => Math.max(m, stepIndex + 1))
|
||||||
} else {
|
} else {
|
||||||
setView('end')
|
setView('end')
|
||||||
}
|
}
|
||||||
@ -90,6 +109,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
setView('list')
|
setView('list')
|
||||||
setActivePath(null)
|
setActivePath(null)
|
||||||
setStepIndex(0)
|
setStepIndex(0)
|
||||||
|
setMaxReached(0)
|
||||||
setCompletedSteps(new Set())
|
setCompletedSteps(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,8 +117,10 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const stepsHaveGeo = steps.some((s) => !!extractStepPoint(s.geometry?.coordinates))
|
// `ShowMap` est le commutateur « parcours géolocalisé ou pas » — il décide seul du mode
|
||||||
const useMapView = section.parcours?.showMap === true && stepsHaveGeo
|
// d'affichage, comme dans mymuseum-visitapp. `BaseSectionMapId` n'est qu'un fond de carte
|
||||||
|
// optionnel et ne conditionne rien.
|
||||||
|
const useMapView = section.parcours?.showMap === true
|
||||||
|
|
||||||
if (view === 'progress' && activePath && currentStep) {
|
if (view === 'progress' && activePath && currentStep) {
|
||||||
if (useMapView) {
|
if (useMapView) {
|
||||||
@ -116,6 +138,8 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
onBack={() => setView('list')}
|
onBack={() => setView('list')}
|
||||||
|
canJumpTo={canJumpTo}
|
||||||
|
onJump={jumpToStep}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -131,6 +155,9 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
onBack={() => setView('list')}
|
onBack={() => setView('list')}
|
||||||
|
maxReached={maxReached}
|
||||||
|
canJumpTo={canJumpTo}
|
||||||
|
onJump={jumpToStep}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -260,7 +287,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
|
|
||||||
// ── Map-based progression ─────────────────────────────────────────────────────
|
// ── Map-based progression ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack }: {
|
function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack, canJumpTo, onJump }: {
|
||||||
path: GuidedPathDTO
|
path: GuidedPathDTO
|
||||||
steps: GuidedStepDTO[]
|
steps: GuidedStepDTO[]
|
||||||
stepIndex: number
|
stepIndex: number
|
||||||
@ -273,6 +300,8 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
|||||||
onNext: () => void
|
onNext: () => void
|
||||||
onPrev: () => void
|
onPrev: () => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
canJumpTo: (i: number) => boolean
|
||||||
|
onJump: (i: number) => void
|
||||||
}) {
|
}) {
|
||||||
const currentStep = steps[stepIndex]
|
const currentStep = steps[stepIndex]
|
||||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(currentStep?.id ?? null)
|
const [selectedStepId, setSelectedStepId] = useState<string | null>(currentStep?.id ?? null)
|
||||||
@ -457,7 +486,13 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
|||||||
primaryColor={primaryColor}
|
primaryColor={primaryColor}
|
||||||
pathSteps={visibleSteps}
|
pathSteps={visibleSteps}
|
||||||
selectedStepId={selectedStepId}
|
selectedStepId={selectedStepId}
|
||||||
onSelectStep={setSelectedStepId}
|
onSelectStep={(id) => {
|
||||||
|
setSelectedStepId(id)
|
||||||
|
// En navigation libre, taper un pin amène directement à l'étape.
|
||||||
|
const i = steps.findIndex((s) => s.id === id)
|
||||||
|
if (i >= 0 && canJumpTo(i)) onJump(i)
|
||||||
|
}}
|
||||||
|
lockedFromIndex={path.isLinear === false ? undefined : stepIndex + 1}
|
||||||
currentStepId={currentStep?.id ?? null}
|
currentStepId={currentStep?.id ?? null}
|
||||||
completedStepIds={completedSteps}
|
completedStepIds={completedSteps}
|
||||||
userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null}
|
userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null}
|
||||||
@ -1272,7 +1307,7 @@ function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack }: {
|
function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack, maxReached, canJumpTo, onJump }: {
|
||||||
path: GuidedPathDTO
|
path: GuidedPathDTO
|
||||||
steps: GuidedStepDTO[]
|
steps: GuidedStepDTO[]
|
||||||
stepIndex: number
|
stepIndex: number
|
||||||
@ -1283,6 +1318,9 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
|||||||
onNext: () => void
|
onNext: () => void
|
||||||
onPrev: () => void
|
onPrev: () => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
maxReached: number
|
||||||
|
canJumpTo: (i: number) => boolean
|
||||||
|
onJump: (i: number) => void
|
||||||
}) {
|
}) {
|
||||||
const step = steps[stepIndex]
|
const step = steps[stepIndex]
|
||||||
const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({})
|
const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({})
|
||||||
@ -1318,6 +1356,11 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
|||||||
const canAdvance = !hasQuiz || !requireSuccess || quizPassed
|
const canAdvance = !hasQuiz || !requireSuccess || quizPassed
|
||||||
const hasTimer = !!step?.isStepTimer && (step?.timerSeconds ?? 0) > 0
|
const hasTimer = !!step?.isStepTimer && (step?.timerSeconds ?? 0) > 0
|
||||||
|
|
||||||
|
// Étapes futures masquées tant que la progression ne les a pas atteintes.
|
||||||
|
const visibleSteps = path.hideNextStepsUntilComplete
|
||||||
|
? steps.slice(0, Math.min(maxReached + 1, steps.length))
|
||||||
|
: steps
|
||||||
|
|
||||||
const allChallengeQuestions = [...mcqQuestions, ...puzzleQuestions, ...simpleQuestions]
|
const allChallengeQuestions = [...mcqQuestions, ...puzzleQuestions, ...simpleQuestions]
|
||||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
const wrongChallengeCount = allChallengeQuestions
|
const wrongChallengeCount = allChallengeQuestions
|
||||||
@ -1429,19 +1472,26 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
|||||||
<ChevronLeft color={isGame ? '#D4AF37' : '#1E2A33'} />
|
<ChevronLeft color={isGame ? '#D4AF37' : '#1E2A33'} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-1 gap-1 items-center">
|
<div className="flex flex-1 gap-1 items-center">
|
||||||
{steps.map((s, i) => (
|
{visibleSteps.map((s, i) => {
|
||||||
<div
|
const reachable = canJumpTo(i)
|
||||||
key={s.id}
|
const background = i < stepIndex
|
||||||
style={{
|
|
||||||
flex: 1, height: 5, borderRadius: 3,
|
|
||||||
background: i < stepIndex
|
|
||||||
? (isGame ? '#D4AF37' : '#2E9E6B')
|
? (isGame ? '#D4AF37' : '#2E9E6B')
|
||||||
: i === stepIndex
|
: i === stepIndex
|
||||||
? accentColor
|
? accentColor
|
||||||
: (isGame ? 'rgba(255,255,255,0.1)' : '#E2DCD2'),
|
: (isGame ? 'rgba(255,255,255,0.1)' : '#E2DCD2')
|
||||||
}}
|
return reachable ? (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJump(i)}
|
||||||
|
aria-label={`Aller à l'étape ${i + 1}`}
|
||||||
|
aria-current={i === stepIndex ? 'step' : undefined}
|
||||||
|
style={{ flex: 1, height: 5, borderRadius: 3, background, padding: 0, border: 'none', cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<div key={s.id} style={{ flex: 1, height: 5, borderRadius: 3, background }} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@ -2227,19 +2277,25 @@ function StepCarousel({ contents, stepIndexKey, isGame, accentColor, language }:
|
|||||||
useEffect(() => { setImgIndex(0) }, [stepIndexKey])
|
useEffect(() => { setImgIndex(0) }, [stepIndexKey])
|
||||||
|
|
||||||
const current = contents[imgIndex]
|
const current = contents[imgIndex]
|
||||||
|
// Une étape accepte image, vidéo et audio : rendre systématiquement en <img>
|
||||||
|
// affichait une image cassée pour les deux autres.
|
||||||
|
const currentIsImage = isImageResource(current?.resource?.type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 220, position: 'relative', overflow: 'hidden', background: isGame ? '#0a0e14' : '#1E2A33' }}>
|
<div style={{ height: 220, position: 'relative', overflow: 'hidden', background: isGame ? '#0a0e14' : '#1E2A33' }}>
|
||||||
{current?.resource?.url && (
|
{current?.resource?.url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<div
|
||||||
<img
|
onClick={currentIsImage ? () => setLightboxOpen(true) : undefined}
|
||||||
src={current.resource.url}
|
style={{ position: 'absolute', inset: 0, cursor: currentIsImage ? 'zoom-in' : 'default' }}
|
||||||
|
>
|
||||||
|
<ResourceViewer
|
||||||
|
resource={current.resource}
|
||||||
alt={tPlain(current.title, language)}
|
alt={tPlain(current.title, language)}
|
||||||
onClick={() => setLightboxOpen(true)}
|
objectFit="cover"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', cursor: 'zoom-in' }}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{lightboxOpen && current?.resource?.url && (
|
{lightboxOpen && currentIsImage && current?.resource?.url && (
|
||||||
<ContentLightbox content={current} isGame={isGame} language={language} onClose={() => setLightboxOpen(false)} />
|
<ContentLightbox content={current} isGame={isGame} language={language} onClose={() => setLightboxOpen(false)} />
|
||||||
)}
|
)}
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.3), transparent 60%)', pointerEvents: 'none' }} />
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.3), transparent 60%)', pointerEvents: 'none' }} />
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, Polygon, useMap } from 'reac
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types'
|
import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types'
|
||||||
import { getStepGeometryCenter, getStepGeometryShape } from '@/lib/geo'
|
import { getGeoPointLatLng, getStepGeometryCenter, getStepGeometryShape } from '@/lib/geo'
|
||||||
|
|
||||||
interface PathStep {
|
interface PathStep {
|
||||||
id: string
|
id: string
|
||||||
@ -30,16 +30,23 @@ interface Props {
|
|||||||
currentStepId?: string | null
|
currentStepId?: string | null
|
||||||
completedStepIds?: Set<string>
|
completedStepIds?: Set<string>
|
||||||
userPosition?: { lat: number; lng: number } | null
|
userPosition?: { lat: number; lng: number } | null
|
||||||
|
/// Index à partir duquel les étapes sont verrouillées. `undefined` = navigation libre.
|
||||||
|
lockedFromIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pinIcon(color: string, selected: boolean) {
|
function pinIcon(color: string, selected: boolean, imageUrl?: string) {
|
||||||
const size = selected ? 44 : 36
|
const size = selected ? 44 : 36
|
||||||
const headSize = selected ? 32 : 26
|
const headSize = selected ? 32 : 26
|
||||||
|
// Icône de catégorie : image uploadée dans manager-app, posée dans la tête du pin
|
||||||
|
// — même source que `getByteIcons` côté mymuseum-visitapp (`categorie.resourceDTO`).
|
||||||
|
const head = imageUrl
|
||||||
|
? `<div class="mim-pin-head"><img src="${imageUrl}" alt="" /></div>`
|
||||||
|
: '<div class="mim-pin-head"></div>'
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'mim-pin-wrapper',
|
className: 'mim-pin-wrapper',
|
||||||
html: `
|
html: `
|
||||||
<div class="mim-pin ${selected ? 'mim-pin-selected' : ''}" style="--pin-color:${color};--pin-size:${size}px;--pin-head:${headSize}px">
|
<div class="mim-pin ${selected ? 'mim-pin-selected' : ''}" style="--pin-color:${color};--pin-size:${size}px;--pin-head:${headSize}px">
|
||||||
<div class="mim-pin-head"></div>
|
${head}
|
||||||
<div class="mim-pin-dot"></div>
|
<div class="mim-pin-dot"></div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@ -115,16 +122,20 @@ export default function LeafletMap({
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
completedStepIds,
|
completedStepIds,
|
||||||
userPosition,
|
userPosition,
|
||||||
|
lockedFromIndex,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const categoryColor = (id?: number): string => {
|
const categoryColor = (id?: number): string => {
|
||||||
if (id == null) return primaryColor
|
if (id == null) return primaryColor
|
||||||
return categories.find((c) => c.id === id)?.color || primaryColor
|
return categories.find((c) => c.id === id)?.color || primaryColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryIconUrl = (id?: number): string | undefined => {
|
||||||
|
if (id == null) return undefined
|
||||||
|
return categories.find((c) => c.id === id)?.resourceDTO?.url
|
||||||
|
}
|
||||||
|
|
||||||
const selected = points.find((p) => p.id === selectedId)
|
const selected = points.find((p) => p.id === selectedId)
|
||||||
const flyTarget: [number, number] | null = selected?.geometry?.coordinates
|
const flyTarget: [number, number] | null = getGeoPointLatLng(selected?.geometry)
|
||||||
? [selected.geometry.coordinates[1], selected.geometry.coordinates[0]]
|
|
||||||
: null
|
|
||||||
|
|
||||||
const stepCoords: PathStep[] = useMemo(() => {
|
const stepCoords: PathStep[] = useMemo(() => {
|
||||||
if (!pathSteps || pathSteps.length === 0) return []
|
if (!pathSteps || pathSteps.length === 0) return []
|
||||||
@ -150,7 +161,12 @@ export default function LeafletMap({
|
|||||||
const stepState = (id: string): StepState => {
|
const stepState = (id: string): StepState => {
|
||||||
if (completedStepIds?.has(id)) return 'completed'
|
if (completedStepIds?.has(id)) return 'completed'
|
||||||
if (id === currentStepId) return 'current'
|
if (id === currentStepId) return 'current'
|
||||||
if (pathSteps?.find((x) => x.id === id)?.isStepLocked) return 'locked'
|
// Une étape est verrouillée tant que la progression ne l'a pas atteinte
|
||||||
|
// (`lockedFromIndex` non défini = navigation libre, rien n'est verrouillé).
|
||||||
|
if (lockedFromIndex != null) {
|
||||||
|
const i = pathSteps?.findIndex((x) => x.id === id) ?? -1
|
||||||
|
if (i >= lockedFromIndex) return 'locked'
|
||||||
|
}
|
||||||
return 'future'
|
return 'future'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,16 +192,14 @@ export default function LeafletMap({
|
|||||||
|
|
||||||
{/* POI markers — dimmed when a path is active */}
|
{/* POI markers — dimmed when a path is active */}
|
||||||
{points.map((p) => {
|
{points.map((p) => {
|
||||||
const coords = p.geometry?.coordinates
|
const position = getGeoPointLatLng(p.geometry)
|
||||||
if (!coords || coords.length < 2) return null
|
if (!position) return null
|
||||||
const lat = coords[1]
|
|
||||||
const lng = coords[0]
|
|
||||||
const isSelected = p.id === selectedId
|
const isSelected = p.id === selectedId
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={`p-${p.id}`}
|
key={`p-${p.id}`}
|
||||||
position={[lat, lng]}
|
position={position}
|
||||||
icon={pinIcon(categoryColor(p.categorieId), isSelected)}
|
icon={pinIcon(categoryColor(p.categorieId), isSelected, categoryIconUrl(p.categorieId))}
|
||||||
eventHandlers={{ click: () => onSelect(p.id) }}
|
eventHandlers={{ click: () => onSelect(p.id) }}
|
||||||
opacity={stepCoords.length > 0 ? 0.45 : 1}
|
opacity={stepCoords.length > 0 ? 0.45 : 1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -34,6 +34,18 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icône de catégorie posée dans la tête du pin : contre-rotation pour compenser
|
||||||
|
le -45deg du pin, et marge pour ne pas toucher la bordure blanche. */
|
||||||
|
.mim-pin-head img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 15%;
|
||||||
|
object-fit: contain;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.mim-pin-dot {
|
.mim-pin-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@ -9,6 +9,11 @@ const VIDEOS = new Set([1, 3, 'Video', 'VideoUrl'])
|
|||||||
const AUDIOS = new Set([4, 'Audio'])
|
const AUDIOS = new Set([4, 'Audio'])
|
||||||
const VIDEO_URLS = new Set([3, 'VideoUrl'])
|
const VIDEO_URLS = new Set([3, 'VideoUrl'])
|
||||||
|
|
||||||
|
/** An absent type falls back to image rendering below — treat it as an image here too. */
|
||||||
|
export function isImageResource(type: unknown): boolean {
|
||||||
|
return type === undefined || type === null || IMAGES.has(type as never)
|
||||||
|
}
|
||||||
|
|
||||||
function youtubeEmbedUrl(url: string): string | null {
|
function youtubeEmbedUrl(url: string): string | null {
|
||||||
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
||||||
const id = match?.[1]
|
const id = match?.[1]
|
||||||
|
|||||||
15
src/components/ui/TrialWatermark.tsx
Normal file
15
src/components/ui/TrialWatermark.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export default function TrialWatermark() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-3 left-1/2 -translate-x-1/2 z-[9999] px-3 py-1.5 rounded-full text-xs font-semibold pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.72)',
|
||||||
|
color: 'white',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aperçu — MyInfoMate
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO } from './types'
|
import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO, AiChatMessageDTO, AiChatResponseDTO } from './types'
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
@ -149,6 +149,11 @@ export async function getInstanceBySlug(slug: string): Promise<ApplicationInstan
|
|||||||
publicApiKey: raw.publicApiKey,
|
publicApiKey: raw.publicApiKey,
|
||||||
webSlug: raw.webSlug,
|
webSlug: raw.webSlug,
|
||||||
label: raw.name,
|
label: raw.name,
|
||||||
|
isTrialActive: raw.isTrialActive,
|
||||||
|
trialEndsAt: raw.trialEndsAt,
|
||||||
|
// L'assistant exige les deux drapeaux, comme AiController : activé sur l'instance
|
||||||
|
// ET sur l'app Web. On expose le résultat combiné pour ne pas avoir à le rejouer.
|
||||||
|
isAssistant: raw.isAssistant === true && webInstance.isAssistant === true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,3 +219,40 @@ export async function getGuidedPathsForParcours(sectionParcoursId: string, apiKe
|
|||||||
const raw: any[] = await apiFetch(`/api/SectionParcours/${sectionParcoursId}/guided-path`, apiKey)
|
const raw: any[] = await apiFetch(`/api/SectionParcours/${sectionParcoursId}/guided-path`, apiKey)
|
||||||
return normalizeGuidedPaths(raw)
|
return normalizeGuidedPaths(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Levée quand l'instance a épuisé son quota IA du mois (HTTP 429). Le visiteur ne
|
||||||
|
/// doit jamais lire le motif technique : l'appelant affiche un message neutre.
|
||||||
|
export class AssistantUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('assistant-unavailable')
|
||||||
|
this.name = 'AssistantUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAssistantMessage(
|
||||||
|
params: {
|
||||||
|
message: string
|
||||||
|
instanceId: string
|
||||||
|
configurationId?: string
|
||||||
|
language: string
|
||||||
|
history: AiChatMessageDTO[]
|
||||||
|
},
|
||||||
|
apiKey: string
|
||||||
|
): Promise<AiChatResponseDTO> {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/AI/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: params.message,
|
||||||
|
instanceId: params.instanceId,
|
||||||
|
configurationId: params.configurationId,
|
||||||
|
language: params.language,
|
||||||
|
appType: 'Web',
|
||||||
|
history: params.history,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 429) throw new AssistantUnavailableError()
|
||||||
|
if (!res.ok) throw new Error(`API error ${res.status}: /api/AI/chat`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,42 @@ export interface ApplicationInstanceDTO {
|
|||||||
publicApiKey?: string
|
publicApiKey?: string
|
||||||
sectionEventId?: string
|
sectionEventId?: string
|
||||||
sectionEventDTO?: SectionDTO
|
sectionEventDTO?: SectionDTO
|
||||||
|
isTrialActive?: boolean
|
||||||
|
trialEndsAt?: string
|
||||||
|
/// Assistant activé sur l'instance. Il doit l'être aussi sur l'app (AppType.Web)
|
||||||
|
/// pour que la bulle apparaisse.
|
||||||
|
isAssistant?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assistant ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiCardDTO {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
/// Emoji choisi par le modèle
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiNavigationDTO {
|
||||||
|
sectionId: string
|
||||||
|
sectionTitle: string
|
||||||
|
sectionType: string
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatMessageDTO {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatResponseDTO {
|
||||||
|
reply: string
|
||||||
|
cards?: AiCardDTO[]
|
||||||
|
navigation?: AiNavigationDTO
|
||||||
|
/// false = le modèle n'attend pas de réponse (politesse, info pure, hors-sujet).
|
||||||
|
/// Utilisé pour ne pas réarmer la dictée après une réponse close.
|
||||||
|
expectsReply?: boolean
|
||||||
|
tokensUsed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigurationDTO {
|
export interface ConfigurationDTO {
|
||||||
@ -210,6 +246,9 @@ export interface CategorieDTO {
|
|||||||
label?: TranslationDTO[]
|
label?: TranslationDTO[]
|
||||||
color?: string
|
color?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
/// Image uploadée servant d'icône de marqueur pour cette catégorie — c'est elle
|
||||||
|
/// que mymuseum-visitapp utilise (`getByteIcons`), pas le champ `icon`.
|
||||||
|
resourceDTO?: ResourceDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapDTO {
|
export interface MapDTO {
|
||||||
@ -256,9 +295,7 @@ export interface GuidedStepDTO {
|
|||||||
isGeoTriggered?: boolean
|
isGeoTriggered?: boolean
|
||||||
zoneRadiusMeters?: number
|
zoneRadiusMeters?: number
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
isHiddenInitially?: boolean
|
|
||||||
isStepTimer?: boolean
|
isStepTimer?: boolean
|
||||||
isStepLocked?: boolean
|
|
||||||
timerSeconds?: number
|
timerSeconds?: number
|
||||||
timerExpiredMessage?: TranslationDTO[]
|
timerExpiredMessage?: TranslationDTO[]
|
||||||
quizQuestions?: QuestionDTO[]
|
quizQuestions?: QuestionDTO[]
|
||||||
|
|||||||
91
src/lib/assistantSuggestions.test.ts
Normal file
91
src/lib/assistantSuggestions.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildSuggestions } from './assistantSuggestions'
|
||||||
|
import type { SectionDTO } from './api/types'
|
||||||
|
|
||||||
|
function section(partial: Partial<SectionDTO> & { id: string; type: string }): SectionDTO {
|
||||||
|
return {
|
||||||
|
title: [{ language: 'FR', value: partial.id }],
|
||||||
|
...partial,
|
||||||
|
} as SectionDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSuggestions', () => {
|
||||||
|
it('propose des questions génériques quand la configuration est vide', () => {
|
||||||
|
const suggestions = buildSuggestions([], 'FR')
|
||||||
|
expect(suggestions).toHaveLength(3)
|
||||||
|
expect(suggestions).toContain("Que voir si je n'ai qu'une heure ?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propose l'agenda seulement si le lieu en a un", () => {
|
||||||
|
const withAgenda = buildSuggestions([section({ id: 'a', type: 'Agenda' })], 'FR')
|
||||||
|
const without = buildSuggestions([section({ id: 'v', type: 'Video' })], 'FR')
|
||||||
|
|
||||||
|
expect(withAgenda).toContain("Qu'est-ce qu'il y a cette semaine ?")
|
||||||
|
expect(without).not.toContain("Qu'est-ce qu'il y a cette semaine ?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cite un point réel de la carte plutôt que le nom de la section', () => {
|
||||||
|
const map = section({
|
||||||
|
id: 'm',
|
||||||
|
type: 'Map',
|
||||||
|
title: [{ language: 'FR', value: 'Plan du musée' }],
|
||||||
|
})
|
||||||
|
;(map as any).map = {
|
||||||
|
points: [{ id: 1, title: [{ language: 'FR', value: 'Salle des orfèvres' }] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buildSuggestions([map], 'FR')).toContain('Où se trouve « Salle des orfèvres » ?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met la section ouverte en première suggestion', () => {
|
||||||
|
const current = section({ id: 's1', type: 'Article', title: [{ language: 'FR', value: 'Le calice' }] })
|
||||||
|
|
||||||
|
const suggestions = buildSuggestions([current], 'FR', current)
|
||||||
|
|
||||||
|
expect(suggestions[0]).toBe('Parlez-moi de « Le calice »')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nettoie le HTML des titres venant de l\'éditeur', () => {
|
||||||
|
const current = section({
|
||||||
|
id: 's1',
|
||||||
|
type: 'Article',
|
||||||
|
title: [{ language: 'FR', value: '<p><strong>Le calice</strong></p>' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(buildSuggestions([current], 'FR', current)[0]).toBe('Parlez-moi de « Le calice »')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traduit les gabarits', () => {
|
||||||
|
const suggestions = buildSuggestions([section({ id: 'a', type: 'Agenda' })], 'EN')
|
||||||
|
expect(suggestions).toContain("What's on this week?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retombe sur le français pour une langue non traduite', () => {
|
||||||
|
expect(buildSuggestions([], 'PL')).toContain("Que voir si je n'ai qu'une heure ?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore les sections désactivées et les sous-sections', () => {
|
||||||
|
const suggestions = buildSuggestions(
|
||||||
|
[
|
||||||
|
section({ id: 'a', type: 'Agenda', isActive: false }),
|
||||||
|
section({ id: 'p', type: 'Parcours', isSubSection: true }),
|
||||||
|
],
|
||||||
|
'FR'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(suggestions).not.toContain("Qu'est-ce qu'il y a cette semaine ?")
|
||||||
|
expect(suggestions).not.toContain('Que faire avec des enfants ?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne dépasse jamais trois suggestions ni ne se répète', () => {
|
||||||
|
const current = section({ id: 'a', type: 'Agenda', title: [{ language: 'FR', value: 'Agenda' }] })
|
||||||
|
const suggestions = buildSuggestions(
|
||||||
|
[current, section({ id: 'p', type: 'Parcours' }), section({ id: 'm', type: 'Map' })],
|
||||||
|
'FR',
|
||||||
|
current
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(suggestions.length).toBeLessThanOrEqual(3)
|
||||||
|
expect(new Set(suggestions).size).toBe(suggestions.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
119
src/lib/assistantSuggestions.ts
Normal file
119
src/lib/assistantSuggestions.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import type { SectionDTO } from './api/types'
|
||||||
|
import { t, stripHtml } from './i18n'
|
||||||
|
|
||||||
|
/// Questions proposées au visiteur quand il ouvre l'assistant.
|
||||||
|
///
|
||||||
|
/// Elles sont dérivées du contenu réel de la configuration — pas configurées par
|
||||||
|
/// le client, pas générées par le modèle : les sections sont déjà chargées, leurs
|
||||||
|
/// titres déjà traduits par le CMS. Un lieu sans agenda ne propose donc pas de
|
||||||
|
/// question sur l'agenda, et le nom qui apparaît est celui que le client a saisi.
|
||||||
|
|
||||||
|
type Lang = string
|
||||||
|
|
||||||
|
const PHRASES: Record<string, Record<Lang, string>> = {
|
||||||
|
aboutSection: {
|
||||||
|
FR: 'Parlez-moi de « {name} »',
|
||||||
|
NL: 'Vertel me over "{name}"',
|
||||||
|
EN: 'Tell me about "{name}"',
|
||||||
|
DE: 'Erzählen Sie mir von „{name}"',
|
||||||
|
ES: 'Háblame de «{name}»',
|
||||||
|
IT: 'Parlami di "{name}"',
|
||||||
|
},
|
||||||
|
whatsOn: {
|
||||||
|
FR: "Qu'est-ce qu'il y a cette semaine ?",
|
||||||
|
NL: 'Wat is er deze week te doen?',
|
||||||
|
EN: "What's on this week?",
|
||||||
|
DE: 'Was gibt es diese Woche?',
|
||||||
|
ES: '¿Qué hay esta semana?',
|
||||||
|
IT: 'Cosa c’è questa settimana?',
|
||||||
|
},
|
||||||
|
withKids: {
|
||||||
|
FR: 'Que faire avec des enfants ?',
|
||||||
|
NL: 'Wat kunnen we doen met kinderen?',
|
||||||
|
EN: 'What can we do with children?',
|
||||||
|
DE: 'Was können wir mit Kindern machen?',
|
||||||
|
ES: '¿Qué hacer con niños?',
|
||||||
|
IT: 'Cosa fare con i bambini?',
|
||||||
|
},
|
||||||
|
whereIs: {
|
||||||
|
FR: 'Où se trouve « {name} » ?',
|
||||||
|
NL: 'Waar is "{name}"?',
|
||||||
|
EN: 'Where is "{name}"?',
|
||||||
|
DE: 'Wo ist „{name}"?',
|
||||||
|
ES: '¿Dónde está «{name}»?',
|
||||||
|
IT: 'Dove si trova "{name}"?',
|
||||||
|
},
|
||||||
|
highlights: {
|
||||||
|
FR: "Qu'est-ce qu'il ne faut pas manquer ?",
|
||||||
|
NL: 'Wat mag ik niet missen?',
|
||||||
|
EN: "What shouldn't I miss?",
|
||||||
|
DE: 'Was sollte ich nicht verpassen?',
|
||||||
|
ES: '¿Qué no me puedo perder?',
|
||||||
|
IT: 'Cosa non devo perdere?',
|
||||||
|
},
|
||||||
|
inOneHour: {
|
||||||
|
FR: "Que voir si je n'ai qu'une heure ?",
|
||||||
|
NL: 'Wat zie ik als ik maar één uur heb?',
|
||||||
|
EN: 'What should I see in one hour?',
|
||||||
|
DE: 'Was sollte ich in einer Stunde sehen?',
|
||||||
|
ES: '¿Qué ver si solo tengo una hora?',
|
||||||
|
IT: 'Cosa vedere se ho solo un’ora?',
|
||||||
|
},
|
||||||
|
openingHours: {
|
||||||
|
FR: 'Quels sont les horaires ?',
|
||||||
|
NL: 'Wat zijn de openingstijden?',
|
||||||
|
EN: 'What are the opening hours?',
|
||||||
|
DE: 'Wie sind die Öffnungszeiten?',
|
||||||
|
ES: '¿Cuál es el horario?',
|
||||||
|
IT: 'Quali sono gli orari?',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function phrase(key: keyof typeof PHRASES, lang: Lang, name?: string): string {
|
||||||
|
const table = PHRASES[key]
|
||||||
|
const template = table[lang] ?? table.FR
|
||||||
|
return name ? template.replace('{name}', name) : template
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionName(section: SectionDTO, lang: Lang): string {
|
||||||
|
const title = stripHtml(t(section.title, lang))
|
||||||
|
return title || section.label || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Au maximum 3 suggestions : au-delà, elles cessent d'être lues.
|
||||||
|
const MAX = 3
|
||||||
|
|
||||||
|
export function buildSuggestions(
|
||||||
|
sections: SectionDTO[] | undefined,
|
||||||
|
lang: Lang,
|
||||||
|
currentSection?: SectionDTO | null
|
||||||
|
): string[] {
|
||||||
|
const visible = (sections ?? []).filter((s) => s.isActive !== false && !s.isSubSection)
|
||||||
|
const has = (type: string) => visible.some((s) => s.type === type)
|
||||||
|
const suggestions: string[] = []
|
||||||
|
|
||||||
|
// La section ouverte passe en premier : c'est ce que le visiteur a sous les yeux.
|
||||||
|
if (currentSection) {
|
||||||
|
const name = sectionName(currentSection, lang)
|
||||||
|
if (name) suggestions.push(phrase('aboutSection', lang, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('Agenda')) suggestions.push(phrase('whatsOn', lang))
|
||||||
|
if (has('Parcours') || has('Game')) suggestions.push(phrase('withKids', lang))
|
||||||
|
|
||||||
|
// Sur une carte, on cite un point réel plutôt qu'une formule creuse.
|
||||||
|
const map = visible.find((s) => s.type === 'Map')
|
||||||
|
if (map) {
|
||||||
|
const point = map.map?.points?.find((p) => stripHtml(t(p.title, lang)))
|
||||||
|
const name = point ? stripHtml(t(point.title, lang)) : sectionName(map, lang)
|
||||||
|
if (name) suggestions.push(phrase('whereIs', lang, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compléments génériques — trois, pour qu'un lieu minimal ait quand même
|
||||||
|
// le compte complet. Ils ne servent que si les spécifiques n'ont pas rempli.
|
||||||
|
suggestions.push(phrase('highlights', lang))
|
||||||
|
suggestions.push(phrase('inOneHour', lang))
|
||||||
|
suggestions.push(phrase('openingHours', lang))
|
||||||
|
|
||||||
|
return [...new Set(suggestions)].slice(0, MAX)
|
||||||
|
}
|
||||||
@ -48,6 +48,17 @@ export function getStepGeometryCenter(geometry: GeometryDTO | undefined): { lat:
|
|||||||
return typeof lat === 'number' && typeof lng === 'number' ? { lat, lng } : null
|
return typeof lat === 'number' && typeof lng === 'number' ? { lat, lng } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeoPoint (SectionMap) and MapAnnotation coordinates follow the GeoJSON convention [lng, lat] —
|
||||||
|
// unlike GuidedStep geometry, which stores [lat, lng] (see getStepGeometryCenter above).
|
||||||
|
// Returns a Leaflet-ready [lat, lng] pair, or null if the geometry isn't a usable point.
|
||||||
|
export function getGeoPointLatLng(geometry: GeometryDTO | undefined): [number, number] | null {
|
||||||
|
const coords = geometry?.coordinates
|
||||||
|
if (!Array.isArray(coords) || coords.length < 2) return null
|
||||||
|
const [lng, lat] = coords as number[]
|
||||||
|
if (typeof lat !== 'number' || typeof lng !== 'number') return null
|
||||||
|
return [lat, lng]
|
||||||
|
}
|
||||||
|
|
||||||
export function haversineMeters(
|
export function haversineMeters(
|
||||||
a: { lat: number; lng: number },
|
a: { lat: number; lng: number },
|
||||||
b: { lat: number; lng: number }
|
b: { lat: number; lng: number }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user