143 lines
5 KiB
XML
143 lines
5 KiB
XML
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>
|
||
);
|
||
}
|