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

@@ -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",

View File

@@ -5012,7 +5012,15 @@
"tabActivate": "فعال‌سازی",
"tabBuy": "خرید",
"tabMyGifts": "هدایای من",
"unlimitedTraffic": "ترافیک نامحدود"
"unlimitedTraffic": "ترافیک نامحدود",
"qrHint": "برای فعال‌سازی هدیه اسکن کنید",
"scanButton": "📷 اسکن QR",
"scanInProgress": "در حال اسکن…",
"scanCancel": "توقف اسکن",
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
"scanNotRecognized": "در این QR کد هدیه‌ای نیست",
"scanError": "اسکن ناموفق بود",
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
},
"news": {
"title": "اخبار و به‌روزرسانی‌ها",

View File

@@ -6058,7 +6058,15 @@
"shareModalActivateVia": "Активировать через бота:",
"shareModalActivateViaCabinet": "Или через сайт:",
"copyMessage": "Скопировать сообщение",
"shareToastCopied": "Сообщение скопировано"
"shareToastCopied": "Сообщение скопировано",
"qrHint": "Отсканируйте, чтобы активировать подарок",
"scanButton": "📷 Отсканировать QR",
"scanInProgress": "Сканирование…",
"scanCancel": "Остановить сканирование",
"scanDescription": "Наведите камеру на QR-код подарка",
"scanNotRecognized": "В QR-коде нет кода подарка",
"scanError": "Не удалось отсканировать",
"scanNoCamera": "Камера недоступна — введите код вручную"
},
"news": {
"title": "Новости и обновления",

View File

@@ -5011,7 +5011,15 @@
"tabActivate": "激活",
"tabBuy": "购买",
"tabMyGifts": "我的礼物",
"unlimitedTraffic": "无限流量"
"unlimitedTraffic": "无限流量",
"qrHint": "扫描以激活礼物",
"scanButton": "📷 扫描二维码",
"scanInProgress": "扫描中…",
"scanCancel": "停止扫描",
"scanDescription": "将摄像头对准礼物二维码",
"scanNotRecognized": "该二维码中没有礼物码",
"scanError": "扫描失败",
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
},
"news": {
"title": "新闻与更新",

View File

@@ -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({
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
</div>
{/* QR — получателю проще отсканировать, чем копировать ссылку.
Кодируем bot-ссылку, если она есть: активация в боте — основной путь,
иначе ссылку кабинета. */}
<div className="flex w-full flex-col items-center gap-2 rounded-xl border border-dark-700/30 bg-white p-4">
<QRCodeSVG value={botLink ?? cabinetLink} size={180} level="M" includeMargin={false} />
</div>
<p className="-mt-3 text-xs text-dark-400">{t('gift.qrHint', 'Scan to activate the gift')}</p>
{/* Share message preview */}
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
<p className="mb-3 text-sm font-medium text-dark-100">

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 */}

View File

@@ -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();
});
});

96
src/utils/qrScanner.ts Normal file
View File

@@ -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<void>;
stop: () => Promise<void>;
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<string | null> {
const result = await openQrScanner({ text, capture: capture ?? (() => true) });
return result ?? null;
}
/** Подгружает html5-qrcode с CDN (в бандл не тянем — нужен редко). */
export async function loadHtml5Qrcode(): Promise<WindowWithHtml5['Html5Qrcode'] | undefined> {
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<void>((resolve) => {
script.onload = () => resolve();
script.onerror = () => resolve();
});
return w.Html5Qrcode;
}
/**
* Достаёт код подарка из отсканированной строки.
*
* Один и тот же подарок распространяется тремя способами, и скан должен понимать
* все: deep-link бота (``?start=GIFT_<code>``), ссылка кабинета
* (``/gift?tab=activate&code=<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;
}