feat(gift): QR подарка и сканирование в кабинете

Получателю раньше оставалось только скопировать ссылку или код руками.

- QR на экране готового подарка: кодируется bot-ссылка (активация в боте —
  основной путь), при отсутствии username бота — ссылка кабинета. Использован
  уже имеющийся qrcode.react, на белой подложке ради контраста в тёмной теме.
- Кнопка «Отсканировать QR» в форме активации. В Telegram используется
  НАТИВНЫЙ сканер (в WebView камера через getUserMedia работает ненадёжно —
  ровно то, из-за чего скан «в тг» и был проблемой), в вебе — html5-qrcode с
  CDN, с фолбэком на фронтальную камеру и понятной ошибкой, если камеры нет.
- src/utils/qrScanner.ts: логика сканера вынесена из TvQuickConnect, чтобы её
  мог переиспользовать любой экран. parseGiftCode понимает все три способа
  распространения подарка (deep-link бота, ссылка кабинета, голый код с
  префиксом GIFT- и без) и ОТКЛОНЯЕТ посторонние QR — иначе сканер подставил
  бы в поле мусор с любой ссылки или Wi-Fi-кода.
- Веб-сканер гасится при уходе с вкладки и размонтировании, иначе индикатор
  камеры остаётся гореть.

Локали ru/en/zh/fa, +4 теста на парсер.
This commit is contained in:
Fringg
2026-07-29 22:11:04 +03:00
parent 390da33914
commit 0e4dc43335
8 changed files with 281 additions and 5 deletions

View File

@@ -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<string | null>(null);
const [scanning, setScanning] = useState(false);
const scannerRef = useRef<Html5QrcodeInstance | null>(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. */}
<button
type="button"
onClick={handleScan}
disabled={scanning}
className="mt-3 w-full rounded-2xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50 disabled:opacity-50"
>
{scanning ? t('gift.scanInProgress') : t('gift.scanButton')}
</button>
{/* Контейнер веб-сканера: html5-qrcode рендерит превью камеры внутрь */}
<div
id="gift-qr-reader"
className={cn('mt-3 overflow-hidden rounded-2xl', !scanning && 'hidden')}
/>
{scanning && (
<button
type="button"
onClick={stopScan}
className="mt-2 w-full rounded-2xl border border-dark-700/50 px-6 py-2 text-xs text-dark-400 transition-colors hover:bg-dark-800/50"
>
{t('gift.scanCancel')}
</button>
)}
</div>
{/* Error */}