import { useState, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { openQrScanner, isQrScannerSupported, retrieveLaunchParams, } from '@telegram-apps/sdk-react'; const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); function isTelegramMobile(): boolean { try { const platform = retrieveLaunchParams().tgWebAppPlatform; return TG_MOBILE_PLATFORMS.has(platform); } catch { return false; } } const HAPP_TV_API = 'https://check.happ.su/sendtv'; const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js'; interface Props { subscriptionUrl: string; isLight: boolean; } interface Html5QrcodeInstance { start: ( cameraIdOrConfig: { facingMode: string } | string, config: { fps: number; qrbox: { width: number; height: number } }, onSuccess: (decoded: string) => void, onError: () => void, ) => Promise; stop: () => Promise; clear: () => void; } export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { const { t } = useTranslation(); const [code, setCode] = useState(''); const [sending, setSending] = useState(false); const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [scanning, setScanning] = useState(false); const [tgNative, setTgNative] = useState(false); const toastTimerRef = useRef>(undefined); const scannerRef = useRef(null); useEffect(() => { try { setTgNative(isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable()); } catch { setTgNative(false); } return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); if (scannerRef.current) { scannerRef.current .stop() .catch(() => undefined) .finally(() => { scannerRef.current?.clear(); scannerRef.current = null; }); } }; }, []); const showToast = useCallback((text: string, type: 'success' | 'error') => { setToast({ text, type }); if (toastTimerRef.current) clearTimeout(toastTimerRef.current); toastTimerRef.current = setTimeout(() => setToast(null), 3000); }, []); const sendToTV = useCallback( async (tvCode: string) => { if (sending) return; const clean = tvCode.trim().toUpperCase(); if (!(clean.length === 5 && /^[A-Z0-9]+$/.test(clean))) { showToast(t('subscription.tvQuickConnect.badCode'), 'error'); return; } setSending(true); try { const b64 = btoa(unescape(encodeURIComponent(subscriptionUrl))); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10000); const res = await fetch(`${HAPP_TV_API}/${encodeURIComponent(clean)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: b64 }), signal: ctrl.signal, }); clearTimeout(timer); if (res.ok) { showToast(t('subscription.tvQuickConnect.sent'), 'success'); setCode(''); } else { showToast(t('subscription.tvQuickConnect.error'), 'error'); } } catch { showToast(t('subscription.tvQuickConnect.error'), 'error'); } finally { setSending(false); } }, [sending, subscriptionUrl, showToast, t], ); const stopScan = useCallback(() => { if (scannerRef.current) { scannerRef.current .stop() .catch(() => undefined) .finally(() => { scannerRef.current?.clear(); scannerRef.current = null; }); } setScanning(false); }, []); const onScanDecoded = useCallback( (decoded: string) => { const parsed = parseQRCode(decoded); if (!parsed) return; stopScan(); setCode(parsed); showToast(`${t('subscription.tvQuickConnect.codeFound')}: ${parsed}`, 'success'); setTimeout(() => sendToTV(parsed), 500); }, [stopScan, showToast, sendToTV, t], ); const startScan = useCallback(async () => { // Telegram Mini App: native scanner if (tgNative) { try { const qr = await openQrScanner({ text: t('subscription.tvQuickConnect.scanDescription'), capture: (s: string) => parseQRCode(s) !== null, }); if (qr) { const parsed = parseQRCode(qr); if (parsed) { setCode(parsed); showToast(t('subscription.tvQuickConnect.codeFound'), 'success'); sendToTV(parsed); } } } catch { showToast(t('subscription.tvQuickConnect.error'), 'error'); } return; } // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; const w = window as WindowWithHtml5; if (!w.Html5Qrcode) { const script = document.createElement('script'); script.src = HTML5_QRCODE_CDN; document.head.appendChild(script); await new Promise((resolve, reject) => { script.onload = () => resolve(); script.onerror = () => reject(); }).catch(() => undefined); } if (!w.Html5Qrcode) { showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); return; } setScanning(true); const scanner = new w.Html5Qrcode('tv-qr-reader'); scannerRef.current = scanner; const config = { fps: 10, qrbox: { width: 220, height: 220 } }; try { await scanner.start({ facingMode: 'environment' }, config, onScanDecoded, () => undefined); } catch { try { await scanner.start({ facingMode: 'user' }, config, onScanDecoded, () => undefined); } catch { showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); scannerRef.current = null; setScanning(false); } } }, [tgNative, sendToTV, showToast, onScanDecoded, t]); const cardClass = isLight ? 'rounded-2xl border border-dark-700/60 bg-white/80 shadow-sm p-4 sm:p-5' : 'rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5'; const inputClass = isLight ? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500' : 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'; return (
{/* Code input */}

{t('subscription.tvQuickConnect.title')}

{t('subscription.tvQuickConnect.description')}

setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} placeholder="A1B2C" autoComplete="one-time-code" inputMode="text" className={inputClass} />
{/* QR Scanner */}

{t('subscription.tvQuickConnect.scanTitle')}

{t('subscription.tvQuickConnect.scanDescription')}

{!scanning && ( )}
{scanning && ( )}
{/* Toast */} {toast && (
{toast.text}
)}
); } function parseQRCode(data: string): string | null { if (data.length === 5 && /^[A-Z0-9]+$/i.test(data)) { return data.toUpperCase(); } try { const url = new URL(data); const parts = url.pathname.split('/').filter((p) => p.length > 0); if (parts.length > 0) { const last = parts[parts.length - 1]; if (last.length === 5 && /^[A-Z0-9]+$/i.test(last)) return last.toUpperCase(); } const codeParam = url.searchParams.get('code'); if (codeParam?.length === 5 && /^[A-Z0-9]+$/i.test(codeParam)) return codeParam.toUpperCase(); } catch { const m = data.match(/[/=]([A-Z0-9]{5})(?:[/?&\s]|$)/i); if (m?.[1]) return m[1].toUpperCase(); } return null; }