vojo/apps/widget-vojo-ai/src/App.tsx

143 lines
5 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'preact/hooks';
import type { WidgetBootstrap } from './bootstrap';
import type { WidgetApi } from './widget-api';
import { createT, type T } from './i18n';
// Must match the host's capability string (catalog.ts BOT_CAP_ADD_TO_CHAT).
const ADD_TO_CHAT_CAP = 'vojo.add_to_chat';
// Lead glyph for the «Добавить в чат» card — a speech bubble with a «+».
// Stroke-only so it picks up `currentColor` and matches the bridge widgets'
// icon language (viewBox 20×20, stroke-width 1.6).
const AddChatIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M3 5.5A2 2 0 0 1 5 3.5h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7.5L4 16.5v-3A2 2 0 0 1 3 11.5z"
stroke-linejoin="round"
/>
<line x1="10" y1="6.2" x2="10" y2="10.8" stroke-linecap="round" />
<line x1="7.7" y1="8.5" x2="12.3" y2="8.5" stroke-linecap="round" />
</svg>
);
// Shield + check — leads the «Конфиденциальность и данные» card (mirrors the
// Telegram widget's info-card-opens-modal pattern).
const ShieldIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 2.5l6 2.2v4.6c0 3.7-2.5 6.4-6 8.2-3.5-1.8-6-4.5-6-8.2V4.7z"
stroke-linejoin="round"
/>
<path d="M7.4 9.8l1.9 1.9 3.3-3.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Full privacy notice, behind a card → modal (Telegram «О боте» pattern). This
// is where the Grok / xAI / 30-day / third-party detail lives — NOT on the
// surface. Backdrop click + Escape close; no focus-trap (small surface).
const AboutModal = ({ t, onClose }: { t: T; onClose: () => void }) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
class="about-overlay"
role="dialog"
aria-modal="true"
aria-label={t('about.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel">
<header class="about-header">
<h2 class="about-title">{t('about.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('about.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p>
<p>{t('about.body-4')}</p>
<p>{t('about.consent')}</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('about.close')}
</button>
</div>
</div>
</div>
);
};
type AppProps = {
bootstrap: WidgetBootstrap;
api: WidgetApi;
};
export function App({ bootstrap, api }: AppProps) {
const t = createT(bootstrap.clientLanguage);
const [aboutOpen, setAboutOpen] = useState(false);
// Render the action ONLY when the host advertised the capability. UI hint
// only — the host re-checks the capability against the trusted config before
// honouring the verb, so a forced render here cannot escalate anything.
const canAddToChat = bootstrap.capabilities.includes(ADD_TO_CHAT_CAP);
// Follow host theme changes (initial theme applied in main.tsx before paint).
useEffect(() => {
api.on('themeChange', (name) => {
document.documentElement.dataset.theme = name;
});
}, [api]);
return (
<div class="app">
<section class="section">
<div class="command-grid">
{canAddToChat && (
<button class="command-card" type="button" onClick={() => api.addToChat()}>
<span class="command-card-lead-icon" aria-hidden="true">
<AddChatIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.add.name')}</div>
<div class="command-card-desc">{t('card.add.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
)}
<button class="command-card" type="button" onClick={() => setAboutOpen(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<ShieldIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.privacy.name')}</div>
<div class="command-card-desc">{t('card.privacy.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
</div>
</section>
{aboutOpen && <AboutModal t={t} onClose={() => setAboutOpen(false)} />}
</div>
);
}