Files
ridebus-status/integrations/StatusWidget.tsx
Zakhar Timoshenko cb1977b65f feat: add RideBus status widget integration
- Implemented StatusWidget component for embedding RideBus service status.
- Created widget-client.js for client-side interaction with the status widget.
- Developed install-widget-integrations script to automate integration into PWA, website, and admin projects.
- Introduced server-side widget rendering in widget.mjs for generating status widget HTML.
- Added comprehensive tests for widget functionality and integration.
2026-10-14 00:24:40 +03:00

64 lines
5.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
type Props = {
url?: string;
locale?: 'ru' | 'en';
theme?: 'light' | 'dark' | 'auto';
services?: string[];
className?: string;
};
type WidgetStatus = 'operational' | 'degraded' | 'down' | 'unknown' | 'maintenance';
/** Embed the public RideBus status without sharing app sessions or API keys. */
export default function StatusWidget({ url, locale = 'ru', theme = 'auto', services = [], className }: Props) {
const frame = useRef<HTMLIFrameElement>(null);
const [ready, setReady] = useState(false);
const [status, setStatus] = useState<WidgetStatus>('unknown');
let source = '', origin = '', destination = '';
try {
if (url) {
const target = new URL(url);
if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) throw new Error('Invalid status URL');
origin = target.origin;
destination = new URL(`/?lang=${locale}`, origin).href;
const embed = new URL('/embed', origin);
embed.searchParams.set('lang', locale);
embed.searchParams.set('theme', theme);
if (services.length) embed.searchParams.set('services', services.join(','));
source = embed.href;
}
} catch { /* An unset or invalid deployment URL leaves the widget disabled. */ }
const title = locale === 'ru' ? 'Статус сервисов RideBus' : 'RideBus service status';
const fallback = locale === 'ru' ? 'Статус не получен' : 'Status unavailable';
const labels: Record<WidgetStatus, string> = locale === 'ru'
? { operational: 'Сервисы работают', degraded: 'Есть перебои', down: 'Сервисы недоступны', unknown: 'Нет свежих данных', maintenance: 'Плановые работы' }
: { operational: 'Services operational', degraded: 'Service disruption', down: 'Services unavailable', unknown: 'No fresh status data', maintenance: 'Scheduled maintenance' };
useEffect(() => {
setReady(false);
if (!source) return;
let lastUpdate = 0;
const request = () => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin);
function receive(event: MessageEvent) {
if (event.origin !== origin || event.source !== frame.current?.contentWindow || event.data?.type !== 'ridebus-status-widget' || event.data.version !== 1 || !['operational', 'degraded', 'down', 'unknown', 'maintenance'].includes(event.data.status)) return;
lastUpdate = Date.now();
setStatus(event.data.status);
setReady(true);
}
function visible() { if (!document.hidden) { if (Date.now() - lastUpdate >= 90000) setReady(false); request(); } }
const timer = setInterval(() => { if (lastUpdate && Date.now() - lastUpdate >= 90000) setReady(false); }, 5000);
window.addEventListener('message', receive);
window.addEventListener('online', request);
document.addEventListener('visibilitychange', visible);
request();
return () => { clearInterval(timer); window.removeEventListener('message', receive); window.removeEventListener('online', request); document.removeEventListener('visibilitychange', visible); };
}, [source, origin]);
if (!source) return null;
return <span className={className} data-ridebus-status-widget style={{ display: 'inline-block', position: 'relative', width: 260, maxWidth: '100%', height: 44, flexShrink: 0, verticalAlign: 'middle' }}>
<style>{'[data-ridebus-status-widget] iframe{transition:transform 160ms cubic-bezier(.2,.75,.25,1)}[data-ridebus-status-widget]:hover iframe{transform:translateY(-1px)}[data-ridebus-status-widget]>a:focus-visible{outline:2px solid #7777aa;outline-offset:2px}@media(prefers-reduced-motion:reduce){[data-ridebus-status-widget] iframe{transition:none}[data-ridebus-status-widget]:hover iframe{transform:none}}'}</style>
<iframe ref={frame} src={source} title={title} width="260" height="44" loading="lazy" referrerPolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" aria-hidden="true" tabIndex={-1} onLoad={() => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin)} style={{ display: 'block', width: '100%', height: 44, border: 0, visibility: ready ? 'visible' : 'hidden', borderRadius: 14, pointerEvents: 'none' }} />
<a href={destination} target="_blank" rel="noopener noreferrer" aria-label={`${title}: ${ready ? labels[status] : fallback}`} aria-live="polite" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px', borderRadius: 14, background: ready ? 'transparent' : theme === 'dark' ? '#202636' : '#f0f1f5', color: theme === 'dark' ? '#bac2d5' : '#636b80', fontSize: 13, fontWeight: 700, textDecoration: 'none', whiteSpace: 'nowrap' }}>{!ready && <><span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', flexShrink: 0 }} />{fallback}<span aria-hidden="true" style={{ marginLeft: 'auto' }}>↗</span></>}</a>
</span>;
}