- 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.
64 lines
5.0 KiB
TypeScript
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>;
|
|
}
|