diff --git a/src/locales/en.json b/src/locales/en.json index de4dde5..c5af0e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -5504,7 +5504,15 @@ "shareModalActivateVia": "Activate via bot:", "shareModalActivateViaCabinet": "Or via website:", "copyMessage": "Copy message", - "shareToastCopied": "Message copied" + "shareToastCopied": "Message copied", + "qrHint": "Scan to activate the gift", + "scanButton": "📷 Scan QR", + "scanInProgress": "Scanning…", + "scanCancel": "Stop scanning", + "scanDescription": "Point the camera at the gift QR code", + "scanNotRecognized": "No gift code in this QR", + "scanError": "Could not scan", + "scanNoCamera": "Camera unavailable — enter the code manually" }, "news": { "title": "News & Updates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 247abae..6e805a5 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -5012,7 +5012,15 @@ "tabActivate": "فعال‌سازی", "tabBuy": "خرید", "tabMyGifts": "هدایای من", - "unlimitedTraffic": "ترافیک نامحدود" + "unlimitedTraffic": "ترافیک نامحدود", + "qrHint": "برای فعال‌سازی هدیه اسکن کنید", + "scanButton": "📷 اسکن QR", + "scanInProgress": "در حال اسکن…", + "scanCancel": "توقف اسکن", + "scanDescription": "دوربین را روی کد QR هدیه بگیرید", + "scanNotRecognized": "در این QR کد هدیه‌ای نیست", + "scanError": "اسکن ناموفق بود", + "scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید" }, "news": { "title": "اخبار و به‌روزرسانی‌ها", diff --git a/src/locales/ru.json b/src/locales/ru.json index 72f742f..9dd3f21 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -6058,7 +6058,15 @@ "shareModalActivateVia": "Активировать через бота:", "shareModalActivateViaCabinet": "Или через сайт:", "copyMessage": "Скопировать сообщение", - "shareToastCopied": "Сообщение скопировано" + "shareToastCopied": "Сообщение скопировано", + "qrHint": "Отсканируйте, чтобы активировать подарок", + "scanButton": "📷 Отсканировать QR", + "scanInProgress": "Сканирование…", + "scanCancel": "Остановить сканирование", + "scanDescription": "Наведите камеру на QR-код подарка", + "scanNotRecognized": "В QR-коде нет кода подарка", + "scanError": "Не удалось отсканировать", + "scanNoCamera": "Камера недоступна — введите код вручную" }, "news": { "title": "Новости и обновления", diff --git a/src/locales/zh.json b/src/locales/zh.json index 08ca1eb..1e85997 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -5011,7 +5011,15 @@ "tabActivate": "激活", "tabBuy": "购买", "tabMyGifts": "我的礼物", - "unlimitedTraffic": "无限流量" + "unlimitedTraffic": "无限流量", + "qrHint": "扫描以激活礼物", + "scanButton": "📷 扫描二维码", + "scanInProgress": "扫描中…", + "scanCancel": "停止扫描", + "scanDescription": "将摄像头对准礼物二维码", + "scanNotRecognized": "该二维码中没有礼物码", + "scanError": "扫描失败", + "scanNoCamera": "摄像头不可用 — 请手动输入代码" }, "news": { "title": "新闻与更新", diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index a77c549..1fa5622 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; +import { QRCodeSVG } from 'qrcode.react'; import { giftApi } from '../api/gift'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { Spinner } from '@/components/ui/Spinner'; @@ -121,6 +122,14 @@ function CodeOnlySuccessState({

{giftCode}

+ {/* QR — получателю проще отсканировать, чем копировать ссылку. + Кодируем bot-ссылку, если она есть: активация в боте — основной путь, + иначе ссылку кабинета. */} +
+ +
+

{t('gift.qrHint', 'Scan to activate the gift')}

+ {/* Share message preview */}

diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 0bbbaeb..953cade 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -1,11 +1,18 @@ import { uiLocale } from '@/utils/uiLocale'; -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate, useSearchParams, Link } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { giftApi } from '../api/gift'; +import { + canUseTelegramScanner, + loadHtml5Qrcode, + parseGiftCode, + scanWithTelegram, + type Html5QrcodeInstance, +} from '@/utils/qrScanner'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import type { GiftConfig, @@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) { if (initialCode) setCode(initialCode); }, [initialCode]); const [activateError, setActivateError] = useState(null); + const [scanning, setScanning] = useState(false); + const scannerRef = useRef(null); + + const stopScan = useCallback(() => { + if (scannerRef.current) { + scannerRef.current + .stop() + .catch(() => undefined) + .finally(() => { + scannerRef.current?.clear(); + scannerRef.current = null; + }); + } + setScanning(false); + }, []); + + // Веб-сканер держит камеру: гасим его при уходе с вкладки/размонтировании, + // иначе индикатор камеры остаётся гореть. + useEffect(() => stopScan, [stopScan]); + + const applyScannedCode = useCallback( + (decoded: string) => { + const parsed = parseGiftCode(decoded); + if (!parsed) { + setActivateError(t('gift.scanNotRecognized')); + return; + } + setCode(parsed); + setActivateError(null); + }, + [t], + ); + + const handleScan = useCallback(async () => { + setActivateError(null); + + if (canUseTelegramScanner()) { + try { + const decoded = await scanWithTelegram( + t('gift.scanDescription'), + (v) => parseGiftCode(v) !== null, + ); + if (decoded) applyScannedCode(decoded); + } catch { + setActivateError(t('gift.scanError')); + } + return; + } + + const Html5Qrcode = await loadHtml5Qrcode(); + if (!Html5Qrcode) { + setActivateError(t('gift.scanNoCamera')); + return; + } + + setScanning(true); + const scanner = new Html5Qrcode('gift-qr-reader'); + scannerRef.current = scanner; + const config = { fps: 10, qrbox: { width: 220, height: 220 } }; + const onDecoded = (decoded: string) => { + if (parseGiftCode(decoded) === null) return; + stopScan(); + applyScannedCode(decoded); + }; + try { + await scanner.start({ facingMode: 'environment' }, config, onDecoded, () => undefined); + } catch { + try { + await scanner.start({ facingMode: 'user' }, config, onDecoded, () => undefined); + } catch { + setActivateError(t('gift.scanNoCamera')); + scannerRef.current = null; + setScanning(false); + } + } + }, [applyScannedCode, stopScan, t]); const activateMutation = useMutation({ mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode), @@ -810,6 +893,32 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) { className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" aria-label={t('gift.activateTitle')} /> + + {/* Скан QR: в Telegram — нативный сканер (в WebView камера через + getUserMedia работает ненадёжно), в вебе — html5-qrcode. */} + + + {/* Контейнер веб-сканера: html5-qrcode рендерит превью камеры внутрь */} +

+ {scanning && ( + + )}
{/* Error */} diff --git a/src/utils/qrScanner.test.ts b/src/utils/qrScanner.test.ts new file mode 100644 index 0000000..d424501 --- /dev/null +++ b/src/utils/qrScanner.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { parseGiftCode } from './qrScanner'; + +describe('parseGiftCode', () => { + it('reads the bot deep link (main distribution path)', () => { + expect(parseGiftCode('https://t.me/mybot?start=GIFT_abc123def456')).toBe('abc123def456'); + }); + + it('reads the cabinet activation link', () => { + expect(parseGiftCode('https://cab.example.com/gift?tab=activate&code=abc123def456')).toBe( + 'abc123def456', + ); + }); + + it('reads a bare code with and without the GIFT prefix', () => { + expect(parseGiftCode('GIFT-abc123def456')).toBe('abc123def456'); + expect(parseGiftCode('abc123def456')).toBe('abc123def456'); + expect(parseGiftCode(' abc123def456 ')).toBe('abc123def456'); + }); + + it('rejects unrelated QR content instead of filling the field with junk', () => { + // Иначе сканер «распознал» бы любую ссылку и подставил мусор в поле кода + expect(parseGiftCode('https://example.com/some/page')).toBeNull(); + expect(parseGiftCode('WIFI:S:MyNet;T:WPA;P:secret;;')).toBeNull(); + expect(parseGiftCode('short')).toBeNull(); + expect(parseGiftCode('')).toBeNull(); + expect(parseGiftCode(null)).toBeNull(); + expect(parseGiftCode(undefined)).toBeNull(); + }); +}); diff --git a/src/utils/qrScanner.ts b/src/utils/qrScanner.ts new file mode 100644 index 0000000..a1dccb8 --- /dev/null +++ b/src/utils/qrScanner.ts @@ -0,0 +1,96 @@ +import { + isQrScannerSupported, + openQrScanner, + retrieveLaunchParams, +} from '@telegram-apps/sdk-react'; + +// Сканирование QR внутри Telegram: WebView не даёт нормального доступа к камере, +// поэтому на мобильных клиентах используется НАТИВНЫЙ сканер Telegram, а на вебе +// — html5-qrcode с CDN. Логика вынесена из TvQuickConnect, чтобы её мог +// переиспользовать любой экран (подарки и т.д.). + +const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); + +export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js'; + +export interface Html5QrcodeInstance { + start: ( + camera: { facingMode: string }, + config: { fps: number; qrbox: { width: number; height: number } }, + onSuccess: (decoded: string) => void, + onError: () => void, + ) => Promise; + stop: () => Promise; + clear: () => void; +} + +type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; + +export function isTelegramMobile(): boolean { + try { + return TG_MOBILE_PLATFORMS.has(retrieveLaunchParams().tgWebAppPlatform); + } catch { + return false; + } +} + +/** true — доступен нативный сканер Telegram (мобильный клиент). */ +export function canUseTelegramScanner(): boolean { + try { + return isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable(); + } catch { + return false; + } +} + +/** + * Открывает нативный сканер Telegram. Возвращает распознанную строку либо null, + * если пользователь закрыл сканер (или он недоступен). + */ +export async function scanWithTelegram( + text: string, + capture?: (value: string) => boolean, +): Promise { + const result = await openQrScanner({ text, capture: capture ?? (() => true) }); + return result ?? null; +} + +/** Подгружает html5-qrcode с CDN (в бандл не тянем — нужен редко). */ +export async function loadHtml5Qrcode(): Promise { + const w = window as WindowWithHtml5; + if (w.Html5Qrcode) return w.Html5Qrcode; + + const script = document.createElement('script'); + script.src = HTML5_QRCODE_CDN; + document.head.appendChild(script); + await new Promise((resolve) => { + script.onload = () => resolve(); + script.onerror = () => resolve(); + }); + return w.Html5Qrcode; +} + +/** + * Достаёт код подарка из отсканированной строки. + * + * Один и тот же подарок распространяется тремя способами, и скан должен понимать + * все: deep-link бота (``?start=GIFT_``), ссылка кабинета + * (``/gift?tab=activate&code=``) и просто код (``GIFT-xxxx`` или голый). + */ +export function parseGiftCode(raw: string | null | undefined): string | null { + const value = (raw || '').trim(); + if (!value) return null; + + const startParam = value.match(/[?&]start=GIFT_([A-Za-z0-9_-]+)/i); + if (startParam) return startParam[1]; + + const cabinetParam = value.match(/[?&]code=([A-Za-z0-9_-]+)/i); + if (cabinetParam) return cabinetParam[1]; + + // Голый код: допускаем префикс GIFT- и разделители, но не произвольный текст — + // иначе сканер «распознал» бы любую ссылку и подставил мусор в поле. + const bare = value.replace(/^GIFT[-_]/i, ''); + if (/^[A-Za-z0-9]{6,64}$/.test(bare)) return bare; + + return null; +}