mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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:
@@ -5504,7 +5504,15 @@
|
|||||||
"shareModalActivateVia": "Activate via bot:",
|
"shareModalActivateVia": "Activate via bot:",
|
||||||
"shareModalActivateViaCabinet": "Or via website:",
|
"shareModalActivateViaCabinet": "Or via website:",
|
||||||
"copyMessage": "Copy message",
|
"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": {
|
"news": {
|
||||||
"title": "News & Updates",
|
"title": "News & Updates",
|
||||||
|
|||||||
@@ -5012,7 +5012,15 @@
|
|||||||
"tabActivate": "فعالسازی",
|
"tabActivate": "فعالسازی",
|
||||||
"tabBuy": "خرید",
|
"tabBuy": "خرید",
|
||||||
"tabMyGifts": "هدایای من",
|
"tabMyGifts": "هدایای من",
|
||||||
"unlimitedTraffic": "ترافیک نامحدود"
|
"unlimitedTraffic": "ترافیک نامحدود",
|
||||||
|
"qrHint": "برای فعالسازی هدیه اسکن کنید",
|
||||||
|
"scanButton": "📷 اسکن QR",
|
||||||
|
"scanInProgress": "در حال اسکن…",
|
||||||
|
"scanCancel": "توقف اسکن",
|
||||||
|
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
|
||||||
|
"scanNotRecognized": "در این QR کد هدیهای نیست",
|
||||||
|
"scanError": "اسکن ناموفق بود",
|
||||||
|
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "اخبار و بهروزرسانیها",
|
"title": "اخبار و بهروزرسانیها",
|
||||||
|
|||||||
@@ -6058,7 +6058,15 @@
|
|||||||
"shareModalActivateVia": "Активировать через бота:",
|
"shareModalActivateVia": "Активировать через бота:",
|
||||||
"shareModalActivateViaCabinet": "Или через сайт:",
|
"shareModalActivateViaCabinet": "Или через сайт:",
|
||||||
"copyMessage": "Скопировать сообщение",
|
"copyMessage": "Скопировать сообщение",
|
||||||
"shareToastCopied": "Сообщение скопировано"
|
"shareToastCopied": "Сообщение скопировано",
|
||||||
|
"qrHint": "Отсканируйте, чтобы активировать подарок",
|
||||||
|
"scanButton": "📷 Отсканировать QR",
|
||||||
|
"scanInProgress": "Сканирование…",
|
||||||
|
"scanCancel": "Остановить сканирование",
|
||||||
|
"scanDescription": "Наведите камеру на QR-код подарка",
|
||||||
|
"scanNotRecognized": "В QR-коде нет кода подарка",
|
||||||
|
"scanError": "Не удалось отсканировать",
|
||||||
|
"scanNoCamera": "Камера недоступна — введите код вручную"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "Новости и обновления",
|
"title": "Новости и обновления",
|
||||||
|
|||||||
@@ -5011,7 +5011,15 @@
|
|||||||
"tabActivate": "激活",
|
"tabActivate": "激活",
|
||||||
"tabBuy": "购买",
|
"tabBuy": "购买",
|
||||||
"tabMyGifts": "我的礼物",
|
"tabMyGifts": "我的礼物",
|
||||||
"unlimitedTraffic": "无限流量"
|
"unlimitedTraffic": "无限流量",
|
||||||
|
"qrHint": "扫描以激活礼物",
|
||||||
|
"scanButton": "📷 扫描二维码",
|
||||||
|
"scanInProgress": "扫描中…",
|
||||||
|
"scanCancel": "停止扫描",
|
||||||
|
"scanDescription": "将摄像头对准礼物二维码",
|
||||||
|
"scanNotRecognized": "该二维码中没有礼物码",
|
||||||
|
"scanError": "扫描失败",
|
||||||
|
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "新闻与更新",
|
"title": "新闻与更新",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
import { Spinner } from '@/components/ui/Spinner';
|
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>
|
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
|
||||||
</div>
|
</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 */}
|
{/* Share message preview */}
|
||||||
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
|
<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">
|
<p className="mb-3 text-sm font-medium text-dark-100">
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { uiLocale } from '@/utils/uiLocale';
|
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 { createPortal } from 'react-dom';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router';
|
import { useNavigate, useSearchParams, Link } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
|
import {
|
||||||
|
canUseTelegramScanner,
|
||||||
|
loadHtml5Qrcode,
|
||||||
|
parseGiftCode,
|
||||||
|
scanWithTelegram,
|
||||||
|
type Html5QrcodeInstance,
|
||||||
|
} from '@/utils/qrScanner';
|
||||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
import type {
|
import type {
|
||||||
GiftConfig,
|
GiftConfig,
|
||||||
@@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
|||||||
if (initialCode) setCode(initialCode);
|
if (initialCode) setCode(initialCode);
|
||||||
}, [initialCode]);
|
}, [initialCode]);
|
||||||
const [activateError, setActivateError] = useState<string | null>(null);
|
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({
|
const activateMutation = useMutation({
|
||||||
mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode),
|
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"
|
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')}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
|
|||||||
30
src/utils/qrScanner.test.ts
Normal file
30
src/utils/qrScanner.test.ts
Normal 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
96
src/utils/qrScanner.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user