J4 — mention d'information aux visiteurs dans l'assistant

§8.3 des CGU : la mention doit être accessible depuis l'assistant lui-même, là où
la collecte a lieu. Panneau dépliable dans l'en-tête, borné en hauteur et
défilant pour ne pas chasser le fil de conversation hors de l'écran.

Texte repris mot pour mot de DOCS/mention-information-visiteurs.md, comme la
version Dart de mymuseum-visitapp : les trois doivent rester alignés, et une
correction se fait dans le document d'abord.

FR/NL/EN avec repli sur l'anglais, alors que le reste de cette interface parle
six langues : traduire une mention de protection des données sans relecture
humaine serait pire que la servir en anglais.

npm run build vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-13 16:29:36 +02:00
parent 572327a651
commit cb7f045b0b
2 changed files with 107 additions and 0 deletions

View File

@ -53,10 +53,65 @@ const UI: Record<string, Record<string, string>> = {
ES: 'La guía descansa por hoy. Vuelva mañana.', ES: 'La guía descansa por hoy. Vuelva mañana.',
IT: 'La guida riposa per oggi. Torna domani.', IT: 'La guida riposa per oggi. Torna domani.',
}, },
privacy: {
FR: 'Vos questions au guide',
NL: 'Uw vragen aan de gids',
EN: 'Your questions to the guide',
DE: 'Ihre Fragen an den Guide',
ES: 'Sus preguntas a la guía',
IT: 'Le tue domande alla guida',
},
} }
const label = (key: keyof typeof UI, lang: string) => UI[key][lang] ?? UI[key].FR const label = (key: keyof typeof UI, lang: string) => UI[key][lang] ?? UI[key].FR
/// Mention d'information aux visiteurs (§8.3 des CGU) — miroir de
/// `mymuseum-visitapp/lib/Components/VisitorPrivacyNotice.dart`, lui-même repris mot pour
/// mot de `DOCS/mention-information-visiteurs.md`. **Les trois doivent rester alignés.**
///
/// ⚠️ **Trois langues, repli sur l'anglais — c'est un choix, pas un oubli.** Le reste de
/// cette interface parle six langues, mais traduire une mention de protection des données
/// sans relecture humaine serait pire que la servir en anglais : une nuance perdue sur
/// « nous n'enregistrons pas votre adresse IP » n'est pas une coquille d'interface. Les
/// autres langues attendent une traduction relue, à demander avec la relecture juridique.
const PRIVACY_NOTICE: Record<string, string> = {
FR:
"Lorsque vous posez une question à notre guide, votre question et la réponse qui vous est " +
"donnée sont enregistrées. Cela nous sert à repérer ce que nos visiteurs cherchent sans le " +
"trouver, et à compléter nos contenus en conséquence.\n\n" +
"Nous n'enregistrons ni votre nom, ni votre compte, ni votre adresse IP. Un identifiant de " +
"session tiré au hasard permet seulement de relier entre elles les questions d'une même " +
"conversation ; il disparaît avec elle.\n\n" +
"Votre question est transmise à notre fournisseur d'intelligence artificielle (Google) pour " +
"produire la réponse. Le texte de vos questions est supprimé au bout de 90 jours ; seuls des " +
"regroupements par sujet, sans le texte de vos questions, sont conservés au-delà.\n\n" +
"Le champ de question est libre : nous vous invitons à ne pas y saisir d'informations " +
"personnelles.",
NL:
"Wanneer u onze gids een vraag stelt, worden uw vraag en het gegeven antwoord opgeslagen. Zo " +
"zien we wat onze bezoekers zoeken zonder het te vinden, en vullen we onze inhoud aan.\n\n" +
"Wij registreren noch uw naam, noch een account, noch uw IP-adres. Een willekeurig " +
"gegenereerde sessie-identificatie dient enkel om de vragen van eenzelfde gesprek aan elkaar " +
"te koppelen; ze verdwijnt samen met dat gesprek.\n\n" +
"Uw vraag wordt doorgestuurd naar onze aanbieder van kunstmatige intelligentie (Google) om " +
"het antwoord op te stellen. De tekst van uw vragen wordt na 90 dagen verwijderd; daarna " +
"blijven enkel groeperingen per onderwerp bewaard, zonder de tekst van uw vragen.\n\n" +
"Het vraagveld is vrij in te vullen: wij raden u aan er geen persoonsgegevens in te typen.",
EN:
"When you ask our guide a question, your question and the answer you are given are recorded. " +
"This helps us see what our visitors look for without finding it, and improve our content " +
"accordingly.\n\n" +
"We record neither your name, nor an account, nor your IP address. A randomly generated " +
"session identifier only links together the questions of a single conversation; it " +
"disappears with it.\n\n" +
"Your question is sent to our artificial intelligence provider (Google) to produce the " +
"answer. The text of your questions is deleted after 90 days; beyond that, only groupings by " +
"topic are kept, without the text of your questions.\n\n" +
"The question field is free text: we invite you not to enter personal information in it.",
}
const privacyNotice = (lang: string) => PRIVACY_NOTICE[lang] ?? PRIVACY_NOTICE.EN
/// L'API de reconnaissance vocale n'est pas standardisée partout : on ne montre le /// L'API de reconnaissance vocale n'est pas standardisée partout : on ne montre le
/// micro que si le navigateur la propose réellement. /// micro que si le navigateur la propose réellement.
function getSpeechRecognition(): any { function getSpeechRecognition(): any {
@ -77,6 +132,7 @@ export default function AssistantBubble({
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const [listening, setListening] = useState(false) const [listening, setListening] = useState(false)
const [speechReady, setSpeechReady] = useState(false) const [speechReady, setSpeechReady] = useState(false)
const [noticeOpen, setNoticeOpen] = useState(false)
const threadRef = useRef<HTMLDivElement>(null) const threadRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@ -233,6 +289,18 @@ export default function AssistantBubble({
<strong>{label('title', language)}</strong> <strong>{label('title', language)}</strong>
{venueName && <span>{venueName}</span>} {venueName && <span>{venueName}</span>}
</span> </span>
{/* §8.3 des CGU : la mention doit être accessible depuis l'assistant lui-même,
c'est-à-dire la collecte a lieu. */}
<button
type="button"
className="mim-assistant-icon-btn"
onClick={() => setNoticeOpen((v) => !v)}
aria-label={label('privacy', language)}
aria-expanded={noticeOpen}
title={label('privacy', language)}
>
<IconPrivacy />
</button>
<button <button
type="button" type="button"
className="mim-assistant-icon-btn" className="mim-assistant-icon-btn"
@ -243,6 +311,15 @@ export default function AssistantBubble({
</button> </button>
</header> </header>
{noticeOpen && (
<section className="mim-assistant-notice">
<strong>{label('privacy', language)}</strong>
{privacyNotice(language).split('\n\n').map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</section>
)}
<div className="mim-assistant-thread" ref={threadRef} aria-live="polite"> <div className="mim-assistant-thread" ref={threadRef} aria-live="polite">
{turns.length === 0 && ( {turns.length === 0 && (
<div className="mim-assistant-opener"> <div className="mim-assistant-opener">
@ -394,6 +471,12 @@ const IconChat = () => (
const IconClose = () => ( const IconClose = () => (
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.1} aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg> <svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.1} aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
) )
const IconPrivacy = () => (
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
<path d="M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6l-8-3z" />
<path d="M12 11v4M12 8.2v.1" />
</svg>
)
const IconPin = () => ( const IconPin = () => (
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true"> <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" /> <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" />

View File

@ -83,6 +83,30 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex: none; flex: none;
} }
/* Mention d'information visiteurs (§8.3 des CGU). Bornée en hauteur et défilante :
le texte est long et ne doit pas chasser le fil de conversation hors de l'écran. */
.mim-assistant-notice {
flex: none;
max-height: 45%;
overflow-y: auto;
padding: .85rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-2, rgba(0, 0, 0, .03));
font-size: .82rem;
line-height: 1.5;
}
.mim-assistant-notice strong {
display: block;
margin-bottom: .5rem;
font-size: .9rem;
}
.mim-assistant-notice p {
margin: 0 0 .6rem;
}
.mim-assistant-notice p:last-child {
margin-bottom: 0;
}
.mim-assistant-avatar { .mim-assistant-avatar {
width: 32px; width: 32px;
height: 32px; height: 32px;