§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>
496 lines
21 KiB
TypeScript
496 lines
21 KiB
TypeScript
'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.',
|
||
},
|
||
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
|
||
|
||
/// 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
|
||
/// 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 [noticeOpen, setNoticeOpen] = 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>
|
||
{/* §8.3 des CGU : la mention doit être accessible depuis l'assistant lui-même,
|
||
c'est-à-dire là où 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
|
||
type="button"
|
||
className="mim-assistant-icon-btn"
|
||
onClick={() => setOpen(false)}
|
||
aria-label={label('close', language)}
|
||
>
|
||
<IconClose />
|
||
</button>
|
||
</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">
|
||
{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 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 = () => (
|
||
<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>
|
||
)
|