Files
bedolaga-cabinet/src/pages/GiftSubscription.tsx
Fringg 0e4dc43335 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 теста на парсер.
2026-07-29 22:11:04 +03:00

1390 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { uiLocale } from '@/utils/uiLocale';
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,
GiftTariff,
GiftTariffPeriod,
GiftPaymentMethod,
GiftPurchaseRequest,
SentGift,
ReceivedGift,
} from '../api/gift';
import { cn } from '../lib/utils';
import { copyToClipboard } from '../utils/clipboard';
import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format';
import { useCurrency } from '../hooks/useCurrency';
import { usePlatform, useHaptic } from '@/platform';
import { openPaymentUrl } from '../utils/openPaymentUrl';
import {
SparklesIcon,
GiftIcon,
CheckIcon,
CheckCircleIcon,
KeyIcon,
InboxIcon,
ExportIcon,
WarningCircleIcon,
BanIcon,
} from '@/components/icons';
function formatPeriodLabel(
days: number,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const key = `landing.periodLabels.d${days}`;
const result = t(key);
if (result !== key) return result;
const months = Math.floor(days / 30);
const remainder = days % 30;
if (months > 0 && remainder === 0) {
return t('landing.periodLabels.nMonths', { count: months });
}
return t('landing.periodLabels.nDays', { count: days });
}
function getGiftStatusKey(status: string): string {
const statusMap: Record<string, string> = {
pending_activation: 'gift.statusPendingActivation',
delivered: 'gift.statusDelivered',
paid: 'gift.statusAvailable',
pending: 'gift.statusPending',
failed: 'gift.statusFailed',
expired: 'gift.statusExpired',
};
return statusMap[status] ?? 'gift.statusPending';
}
function isGiftAvailable(status: string): boolean {
return status === 'paid' || status === 'delivered' || status === 'pending_activation';
}
function isGiftActivated(gift: SentGift): boolean {
return gift.status === 'delivered' && gift.activated_by_username != null;
}
function formatGiftDate(dateStr: string | null): string {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type TabId = 'buy' | 'activate' | 'myGifts';
function LoadingSkeleton() {
return (
<div className="flex min-h-dvh items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
</div>
</div>
);
}
function ErrorState({ message }: { message: string }) {
const { t } = useTranslation();
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
<WarningCircleIcon className="h-8 w-8 text-error-400" />
</div>
<h2 className="text-lg font-semibold text-dark-50">{t('gift.failedTitle')}</h2>
<p className="text-sm text-dark-300">{message}</p>
</div>
</div>
);
}
function DisabledState() {
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => navigate('/'), 3000);
return () => clearTimeout(timer);
}, [navigate]);
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50">
<BanIcon className="h-8 w-8 text-dark-400" />
</div>
<h2 className="text-lg font-semibold text-dark-50">{t('gift.featureDisabled')}</h2>
<p className="text-sm text-dark-300">{t('gift.redirecting')}</p>
</div>
</div>
);
}
function TariffCard({
tariff,
isSelected,
onSelect,
}: {
tariff: GiftTariff;
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={onSelect}
className={cn(
'flex w-full items-center gap-4 rounded-2xl border p-4 text-start transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50',
)}
>
{/* Gift circle icon */}
<div
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-full transition-colors',
isSelected ? 'bg-accent-500/20' : 'bg-dark-800/50',
)}
>
<GiftIcon
className={cn(
'h-6 w-6 transition-colors',
isSelected ? 'text-accent-400' : 'text-dark-400',
)}
/>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="text-base font-bold text-dark-50">{tariff.name}</p>
<p
className={cn(
'text-xs font-medium uppercase tracking-wider transition-colors',
isSelected ? 'text-accent-400' : 'text-dark-400',
)}
>
{tariff.traffic_limit_gb > 0
? `${tariff.traffic_limit_gb} ${t('gift.gbShort')}`
: t('gift.unlimitedTraffic')}
{' \u2022 '}
{t('gift.deviceCount', { count: tariff.device_limit })}
</p>
</div>
{/* Checkmark circle */}
<div
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && <CheckIcon className="h-3.5 w-3.5 text-white" />}
</div>
</button>
);
}
function PeriodCard({
period,
isSelected,
onSelect,
}: {
period: GiftTariffPeriod;
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const hasDiscount =
period.original_price_kopeks != null && period.original_price_kopeks > period.price_kopeks;
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-center justify-between rounded-2xl p-4 transition-all duration-200',
isSelected
? 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25'
: 'bg-dark-800/50 hover:bg-dark-700/50',
)}
>
{/* Left: period + discount */}
<div className="flex flex-col items-start gap-1">
<span className="text-lg font-bold">{formatPeriodLabel(period.days, t)}</span>
{hasDiscount && period.discount_percent != null && (
<span
className={cn(
'rounded-md px-2 py-0.5 text-xs font-bold',
isSelected ? 'bg-white/20 text-on-accent' : 'bg-accent-500/20 text-accent-400',
)}
>
-{period.discount_percent}%
</span>
)}
</div>
{/* Right: prices */}
<div className="flex flex-col items-end gap-0.5">
<span className={cn('text-lg font-bold', isSelected ? 'text-white' : 'text-accent-400')}>
{formatPrice(period.price_kopeks)}
</span>
{hasDiscount && period.original_price_kopeks != null && (
<span
className={cn('text-xs line-through', isSelected ? 'text-white/50' : 'text-dark-500')}
>
{formatPrice(period.original_price_kopeks)}
</span>
)}
</div>
</button>
);
}
function PaymentModeToggle({
mode,
onToggle,
balanceLabel,
}: {
mode: 'balance' | 'gateway';
onToggle: (mode: 'balance' | 'gateway') => void;
balanceLabel: string;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('gift.paymentMode')}
className="flex rounded-xl bg-dark-800/50 p-1"
>
<button
type="button"
onClick={() => onToggle('balance')}
aria-pressed={mode === 'balance'}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
mode === 'balance'
? 'bg-dark-700 text-dark-50 shadow-sm'
: 'text-dark-400 hover:text-dark-200',
)}
>
{balanceLabel}
</button>
<button
type="button"
onClick={() => onToggle('gateway')}
aria-pressed={mode === 'gateway'}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
mode === 'gateway'
? 'bg-dark-700 text-dark-50 shadow-sm'
: 'text-dark-400 hover:text-dark-200',
)}
>
{t('gift.viaGateway')}
</button>
</div>
);
}
function PaymentMethodCard({
method,
isSelected,
selectedSubOption,
onSelect,
onSelectSubOption,
}: {
method: GiftPaymentMethod;
isSelected: boolean;
selectedSubOption: string | null;
onSelect: () => void;
onSelectSubOption: (subOptionId: string) => void;
}) {
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
return (
<div
className={cn(
'rounded-2xl border transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50',
)}
>
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={onSelect}
className="flex w-full items-center gap-4 p-4 text-start"
>
{method.icon_url && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
{method.description && (
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
)}
</div>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
</div>
</button>
{isSelected && hasSubOptions && (
<div className="border-t border-dark-800/30 px-4 pb-4 pt-3">
<div className="flex flex-wrap gap-2">
{method.sub_options!.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => onSelectSubOption(opt.id)}
className={cn(
'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200',
selectedSubOption === opt.id
? 'bg-accent-500 text-on-accent shadow-sm shadow-accent-500/25'
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50',
)}
>
{opt.name}
</button>
))}
</div>
</div>
)}
</div>
);
}
function BuyTabContent({
config,
onPurchaseComplete,
}: {
config: GiftConfig;
onPurchaseComplete: () => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { openInvoice, capabilities, openLink, platform } = usePlatform();
const haptic = useHaptic();
// Selection state
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Auto-select first tariff, period, method on config load
useEffect(() => {
if (config.tariffs.length > 0 && selectedTariffId === null) {
const firstTariff = config.tariffs[0];
setSelectedTariffId(firstTariff.id);
if (firstTariff.periods.length > 0 && selectedPeriodDays === null) {
setSelectedPeriodDays(firstTariff.periods[0].days);
}
}
if (config.payment_methods.length > 0 && selectedMethod === null) {
const firstMethod = config.payment_methods[0];
setSelectedMethod(firstMethod.method_id);
if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
setSelectedSubOption(firstMethod.sub_options[0].id);
} else {
setSelectedSubOption(null);
}
}
}, [config, selectedTariffId, selectedPeriodDays, selectedMethod]);
// When tariff changes, auto-select its first period
useEffect(() => {
if (!selectedTariffId) return;
const tariff = config.tariffs.find((t) => t.id === selectedTariffId);
if (tariff && tariff.periods.length > 0) {
const hasCurrent = tariff.periods.some((p) => p.days === selectedPeriodDays);
if (!hasCurrent) {
setSelectedPeriodDays(tariff.periods[0].days);
}
}
}, [selectedTariffId, config.tariffs, selectedPeriodDays]);
// Derived data
const selectedTariff = useMemo(
() => config.tariffs.find((tr) => tr.id === selectedTariffId),
[config.tariffs, selectedTariffId],
);
const selectedPeriod = useMemo(
() => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
[selectedTariff, selectedPeriodDays],
);
const currentPrice = selectedPeriod?.price_kopeks ?? 0;
const insufficientBalance = paymentMode === 'balance' && config.balance_kopeks < currentPrice;
// Validation
const canSubmit = useMemo(() => {
if (!selectedTariffId || !selectedPeriodDays) return false;
if (paymentMode === 'gateway' && !selectedMethod) return false;
if (insufficientBalance) return false;
return true;
}, [selectedTariffId, selectedPeriodDays, paymentMode, selectedMethod, insufficientBalance]);
// Purchase mutation
const purchaseMutation = useMutation({
mutationFn: (data: GiftPurchaseRequest) => giftApi.createPurchase(data),
onSuccess: async (result) => {
if (result.payment_url) {
// Telegram Stars: open invoice natively instead of redirect
const isStars = selectedMethod === 'telegram_stars';
if (isStars && capabilities.hasInvoice) {
try {
const status = await openInvoice(result.payment_url);
if (status === 'paid') {
haptic.notification('success');
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['gift-config'] });
queryClient.invalidateQueries({ queryKey: ['gift-sent'] });
onPurchaseComplete();
} else if (status === 'failed') {
haptic.notification('error');
setSubmitError(t('gift.failedDesc'));
}
// 'cancelled' — user closed the invoice, do nothing
} catch {
setSubmitError(t('gift.failedDesc'));
}
return;
}
// Non-Stars provider (RollyPay/YooKassa/SBP …): open externally inside Telegram so
// a bank-app hand-off via custom scheme doesn't dead-end in the WebView (#654272).
openPaymentUrl(result.payment_url, platform, openLink);
} else {
// Balance purchase: switch to MyGifts tab so the new code is visible
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['gift-config'] });
queryClient.invalidateQueries({ queryKey: ['gift-sent'] });
onPurchaseComplete();
}
},
onError: (err) => {
const msg = getApiErrorMessage(err, t('gift.failedDesc'));
setSubmitError(msg);
},
});
// Submit handler
const handleSubmit = () => {
if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending)
return;
setSubmitError(null);
let paymentMethod: string | undefined;
if (paymentMode === 'gateway' && selectedMethod) {
paymentMethod = selectedMethod;
if (selectedSubOption) {
paymentMethod = `${paymentMethod}_${selectedSubOption}`;
}
}
const data: GiftPurchaseRequest = {
tariff_id: selectedTariffId,
period_days: selectedPeriodDays,
payment_mode: paymentMode,
payment_method: paymentMethod,
};
purchaseMutation.mutate(data);
};
// Balance label with current amount
const balanceLabel = useMemo(() => {
return `${t('gift.fromBalance')} (${formatPrice(config.balance_kopeks)})`;
}, [config, t]);
const showTariffCards = config.tariffs.length > 1;
// Periods for the selected tariff (for period cards)
const periodsForDisplay = useMemo(() => {
if (selectedTariff) {
return [...selectedTariff.periods].sort((a, b) => a.days - b.days);
}
return [];
}, [selectedTariff]);
return (
<div className="space-y-6">
{/* Tariff selection */}
{showTariffCards && (
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.selectTariff')}
</h2>
<div role="radiogroup" aria-label={t('gift.chooseTariff')} className="space-y-2">
{config.tariffs.map((tariff) => (
<TariffCard
key={tariff.id}
tariff={tariff}
isSelected={tariff.id === selectedTariffId}
onSelect={() => setSelectedTariffId(tariff.id)}
/>
))}
</div>
</div>
)}
{/* Selected tariff description */}
{selectedTariff?.description && (
<div className="rounded-xl border border-dark-800/30 bg-dark-800/20 px-4 py-3">
<p className="text-sm text-dark-300">{selectedTariff.description}</p>
</div>
)}
{/* Promo group banner */}
{config.promo_group_name && (
<div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-success-500/20">
<SparklesIcon className="h-4 w-4 text-success-400" />
</div>
<div>
<div className="text-sm font-medium text-success-400">
{t('subscription.promoGroup.yourGroup', { name: config.promo_group_name })}
</div>
<div className="text-xs text-dark-400">
{t('subscription.promoGroup.personalDiscountsApplied')}
</div>
</div>
</div>
)}
{/* Active discount banner */}
{config.active_discount_percent != null && config.active_discount_percent > 0 && (
<div className="flex items-center gap-3 rounded-xl border border-warning-500/30 bg-warning-500/10 p-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-warning-500/20">
<SparklesIcon className="h-4 w-4 text-warning-400" />
</div>
<div className="text-sm font-medium text-warning-400">
{t('promo.discountApplied')} -{config.active_discount_percent}%
</div>
</div>
)}
{/* Period selection */}
{periodsForDisplay.length > 0 && (
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.selectPeriod')}
</h2>
<div className="space-y-2">
{periodsForDisplay.map((period) => (
<PeriodCard
key={period.days}
period={period}
isSelected={period.days === selectedPeriodDays}
onSelect={() => setSelectedPeriodDays(period.days)}
/>
))}
</div>
</div>
)}
{/* Payment mode toggle */}
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.paymentMode')}
</h2>
<PaymentModeToggle
mode={paymentMode}
onToggle={setPaymentMode}
balanceLabel={balanceLabel}
/>
</div>
{/* Payment method cards (gateway mode only) */}
<AnimatePresence mode="wait">
{paymentMode === 'gateway' && config.payment_methods.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div role="radiogroup" aria-label={t('gift.paymentMethod')} className="space-y-2">
{config.payment_methods.map((method) => (
<PaymentMethodCard
key={method.method_id}
method={method}
isSelected={method.method_id === selectedMethod}
selectedSubOption={method.method_id === selectedMethod ? selectedSubOption : null}
onSelect={() => {
setSelectedMethod(method.method_id);
if (method.sub_options && method.sub_options.length >= 1) {
setSelectedSubOption(method.sub_options[0].id);
} else {
setSelectedSubOption(null);
}
}}
onSelectSubOption={setSelectedSubOption}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Summary / Balance info */}
{paymentMode === 'balance' && (
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-dark-400">{t('gift.yourBalance')}</span>
<span className="text-sm font-semibold text-dark-200">
{formatPrice(config.balance_kopeks)}
</span>
</div>
</div>
)}
{/* Insufficient balance warning */}
<AnimatePresence>
{insufficientBalance && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-warning-500/20 bg-warning-500/5 p-3"
>
<p className="text-sm text-warning-400">
{t('gift.insufficientBalance')}{' '}
<Link
to="/balance"
className="font-medium text-accent-400 underline underline-offset-2"
>
{t('gift.topUpBalance')}
</Link>
</p>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
<AnimatePresence>
{submitError && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-error-500/20 bg-error-500/5 p-3"
>
<p className="text-sm text-error-400">{submitError}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit button */}
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || purchaseMutation.isPending}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !purchaseMutation.isPending
? 'bg-accent-500 text-on-accent shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{purchaseMutation.isPending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('gift.giftButton')} {currentPrice > 0 ? formatPrice(currentPrice) : ''}
</>
)}
</button>
</div>
);
}
function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [code, setCode] = useState(initialCode ?? '');
// Sync when initialCode changes (e.g. URL param update while tab is active)
useEffect(() => {
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),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gift-received'] });
queryClient.invalidateQueries({ queryKey: ['gift-sent'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
},
onError: (err) => {
const raw = getApiErrorMessage(err, '');
const msg =
raw === 'Cannot activate your own gift'
? t('gift.activateSelfError')
: raw || t('gift.activateError');
setActivateError(msg);
},
});
const handleActivate = () => {
const trimmed = code.trim();
if (!trimmed || activateMutation.isPending) return;
setActivateError(null);
activateMutation.mutate(trimmed);
};
if (activateMutation.isSuccess && activateMutation.data) {
const result = activateMutation.data;
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-accent-500/20">
<CheckCircleIcon className="h-8 w-8 text-accent-400" />
</div>
<h2 className="text-xl font-bold text-dark-50">{t('gift.activateSuccess')}</h2>
<p className="text-sm text-dark-300">
{t('gift.activateSuccessDesc', {
tariff: result.tariff_name ?? '',
days: result.period_days ?? 0,
})}
</p>
</div>
);
}
return (
<div className="flex flex-col items-center gap-6 py-8">
{/* Icon + title */}
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-accent-500/20">
<KeyIcon className="h-8 w-8 text-accent-400" />
</div>
<h2 className="text-xl font-bold text-dark-50">{t('gift.activateTitle')}</h2>
<p className="max-w-xs text-sm text-dark-400">{t('gift.activateDescription')}</p>
</div>
{/* Code input */}
<div className="w-full max-w-sm">
<input
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value);
setActivateError(null);
}}
placeholder={t('gift.activateCodePlaceholder')}
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 */}
<AnimatePresence>
{activateError && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="w-full max-w-sm rounded-xl border border-error-500/20 bg-error-500/5 p-3"
>
<p className="text-center text-sm text-error-400">{activateError}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit */}
<button
type="button"
onClick={handleActivate}
disabled={!code.trim() || activateMutation.isPending}
className={cn(
'w-full max-w-sm rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
code.trim() && !activateMutation.isPending
? 'bg-accent-500 text-on-accent shadow-lg shadow-accent-500/25 hover:bg-accent-400 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{activateMutation.isPending ? (
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
t('gift.activateButton')
)}
</button>
</div>
);
}
function CopiedToast({ onDismiss }: { onDismiss: () => void }) {
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(onDismiss, 2000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed inset-x-0 bottom-6 z-50 flex justify-center"
>
<div className="flex items-center gap-2 rounded-full border border-dark-700/50 bg-dark-900/95 px-5 py-2.5 shadow-2xl shadow-black/40 backdrop-blur-md">
<CheckIcon className="h-4 w-4 text-success-400" />
<span className="text-sm font-medium text-success-400">{t('gift.shareToastCopied')}</span>
</div>
</motion.div>
);
}
function SentGiftCard({ gift }: { gift: SentGift }) {
const { t } = useTranslation();
const [showToast, setShowToast] = useState(false);
// Runtime bot username (env var is only a fallback) so the "activate via bot"
// line never silently disappears when VITE_TELEGRAM_BOT_USERNAME is unset.
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const shortCode = gift.token.slice(0, 12);
const giftCode = `GIFT-${shortCode}`;
const isActivated = isGiftActivated(gift);
const isAvailable = !isActivated && isGiftAvailable(gift.status);
const statusText = isActivated
? t('gift.statusActivated')
: isAvailable
? t('gift.statusAvailable')
: t(getGiftStatusKey(gift.status));
const buildShareMessage = useCallback(() => {
// Literal "GIFT_" prefix: Telegram forwards the start param to the bot
// verbatim (no URL-decoding), so the previously-encoded "%5F" never matched
// the bot's `start_parameter.startswith('GIFT_')` handler.
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${encodeURIComponent(shortCode)}`;
return [
t('gift.shareText'),
'',
botLink ? `${t('gift.shareModalActivateVia')} ${botLink}` : null,
`${t('gift.shareModalActivateViaCabinet')} ${cabinetLink}`,
]
.filter(Boolean)
.join('\n');
}, [shortCode, botUsername, t]);
const handleShare = useCallback(async () => {
const message = buildShareMessage();
await copyToClipboard(message);
setShowToast(true);
}, [buildShareMessage]);
const handleDismissToast = useCallback(() => setShowToast(false), []);
return (
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-4">
{/* Header: tariff name + status badge */}
<div className="mb-3 flex items-start justify-between">
<h3 className="text-base font-bold text-dark-50">{gift.tariff_name ?? t('gift.tariff')}</h3>
<span
className={cn(
'rounded-lg px-2.5 py-1 text-xs font-bold',
isActivated
? 'bg-dark-700 text-dark-400'
: isAvailable
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700 text-dark-400',
)}
>
{statusText}
</span>
</div>
{/* Info line */}
<p className="mb-3 text-xs text-dark-400">
{formatGiftDate(gift.created_at)}
{' \u2022 '}
{gift.period_days} {t('gift.daysShort')}
{' \u2022 '}
{gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}
</p>
{/* Gift code + actions (only when not activated) */}
{!isActivated && (
<>
{/* Gift code display */}
<div className="mb-3 rounded-xl bg-dark-800/80 px-4 py-4 text-center">
<p className="font-mono text-base font-bold tracking-[0.15em] text-accent-400">
{giftCode}
</p>
</div>
{/* Share button — copies message and shows toast */}
<button
type="button"
onClick={handleShare}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-3 text-sm font-bold uppercase tracking-wider text-on-accent transition-colors hover:bg-accent-400 active:scale-[0.98]"
>
<ExportIcon className="h-4 w-4" />
{t('gift.shareGift')}
</button>
</>
)}
{/* Activated by */}
{isActivated && gift.activated_by_username && (
<p className="mt-2 text-xs text-dark-500">
{t('gift.activatedBy', { username: gift.activated_by_username })}
</p>
)}
{/* Sent to */}
{gift.gift_recipient_value && (
<p className="mt-1 text-xs text-dark-500">
{t('gift.sentTo', { recipient: gift.gift_recipient_value })}
</p>
)}
{/* Copied toast — portal to escape motion.div transform context */}
{createPortal(
<AnimatePresence>
{showToast && <CopiedToast onDismiss={handleDismissToast} />}
</AnimatePresence>,
document.body,
)}
</div>
);
}
function ReceivedGiftCard({ gift }: { gift: ReceivedGift }) {
const { t } = useTranslation();
const statusKey = getGiftStatusKey(gift.status);
const statusText = t(statusKey);
return (
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-4">
{/* Header */}
<div className="mb-3 flex items-start justify-between">
<h3 className="text-base font-bold text-dark-50">{gift.tariff_name ?? t('gift.tariff')}</h3>
<span className="rounded-lg bg-dark-700 px-2.5 py-1 text-xs font-bold text-dark-400">
{statusText}
</span>
</div>
{/* Info line */}
<p className="mb-2 text-xs text-dark-400">
{formatGiftDate(gift.created_at)}
{' \u2022 '}
{gift.period_days} {t('gift.daysShort')}
{' \u2022 '}
{gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}
</p>
{/* Sender */}
{gift.sender_display && (
<p className="text-xs text-dark-500">
{t('gift.pending.from', { sender: gift.sender_display })}
</p>
)}
{/* Gift message */}
{gift.gift_message && (
<div className="mt-2 rounded-xl bg-dark-800/50 p-3">
<p className="text-xs italic text-dark-300">{gift.gift_message}</p>
</div>
)}
</div>
);
}
function MyGiftsTabContent() {
const { t } = useTranslation();
const {
data: sentGifts,
isLoading: sentLoading,
error: sentError,
} = useQuery({
queryKey: ['gift-sent'],
queryFn: giftApi.getSentGifts,
staleTime: 30_000,
});
const {
data: receivedGifts,
isLoading: receivedLoading,
error: receivedError,
} = useQuery({
queryKey: ['gift-received'],
queryFn: giftApi.getReceivedGifts,
staleTime: 30_000,
});
const isLoading = sentLoading || receivedLoading;
// Split sent gifts into active (awaiting activation) and activated
const activeGifts = useMemo(
() => (sentGifts ?? []).filter((g) => !isGiftActivated(g)),
[sentGifts],
);
const activatedGifts = useMemo(
() => (sentGifts ?? []).filter((g) => isGiftActivated(g)),
[sentGifts],
);
const hasActive = activeGifts.length > 0;
const hasActivated = activatedGifts.length > 0;
const hasReceived = receivedGifts && receivedGifts.length > 0;
const isEmpty = !hasActive && !hasActivated && !hasReceived;
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
</div>
);
}
if (sentError || receivedError) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-sm text-error-400">{t('gift.failedDesc')}</p>
</div>
);
}
if (isEmpty) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50">
<InboxIcon className="h-8 w-8 text-dark-400" />
</div>
<h2 className="text-lg font-semibold text-dark-200">{t('gift.myGiftsEmpty')}</h2>
<p className="max-w-xs text-sm text-dark-400">{t('gift.myGiftsEmptyDesc')}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Active gifts (awaiting activation) */}
{hasActive && (
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.activeGiftsTitle')}
</h2>
<div className="space-y-3">
{activeGifts.map((gift) => (
<SentGiftCard key={gift.token} gift={gift} />
))}
</div>
</div>
)}
{/* Activated gifts */}
{hasActivated && (
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.activatedGiftsTitle')}
</h2>
<div className="space-y-3">
{activatedGifts.map((gift) => (
<SentGiftCard key={gift.token} gift={gift} />
))}
</div>
</div>
)}
{/* Received gifts */}
{hasReceived && (
<div>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-dark-400">
{t('gift.receivedGiftsTitle')}
</h2>
<div className="space-y-3">
{receivedGifts!.map((gift) => (
<ReceivedGiftCard key={gift.token} gift={gift} />
))}
</div>
</div>
)}
</div>
);
}
const tabContentVariants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
export default function GiftSubscription() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
// Прогреваем кэш курсов валют для formatPrice (см. QuickPurchase).
useCurrency();
// URL params: ?tab=activate&code=TOKEN for auto-activation
const urlTab = searchParams.get('tab') as TabId | null;
const rawCode = searchParams.get('code');
// Strip GIFT- or GIFT_ prefix if user pasted the full display code
const urlCode = rawCode?.replace(/^GIFT[-_]/i, '') ?? rawCode;
const [activeTab, setActiveTab] = useState<TabId>(
urlTab === 'activate' || urlTab === 'myGifts' ? urlTab : 'buy',
);
// Fetch config
const {
data: config,
isLoading,
error,
} = useQuery({
queryKey: ['gift-config'],
queryFn: giftApi.getConfig,
staleTime: 30_000,
});
// Loading state
if (isLoading) {
return <LoadingSkeleton />;
}
// Error state
if (error || !config) {
const errMsg = getApiErrorMessage(error, t('gift.notFound'));
return <ErrorState message={errMsg} />;
}
// Disabled state
if (!config.is_enabled) {
return <DisabledState />;
}
const tabs: { id: TabId; label: string }[] = [
{ id: 'buy', label: t('gift.tabBuy') },
{ id: 'activate', label: t('gift.tabActivate') },
{ id: 'myGifts', label: t('gift.tabMyGifts') },
];
return (
<div className="min-h-dvh">
<div className="mx-auto max-w-2xl px-4 py-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="mb-6 flex items-center gap-3"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-500/20">
<GiftIcon className="h-5 w-5 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-dark-50">{t('gift.pageTitle')}</h1>
</motion.div>
{/* Tab bar */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className="mb-6 rounded-2xl bg-dark-800/50 p-1"
>
<div className="flex" role="tablist" aria-label={t('gift.pageTitle')}>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`tabpanel-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex-1 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
activeTab === tab.id
? 'bg-accent-500 text-on-accent shadow-sm'
: 'text-dark-400 hover:text-dark-200',
)}
>
{tab.label}
</button>
))}
</div>
</motion.div>
{/* Tab content */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
variants={tabContentVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
role="tabpanel"
id={`tabpanel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
>
{activeTab === 'buy' && (
<BuyTabContent config={config} onPurchaseComplete={() => setActiveTab('myGifts')} />
)}
{activeTab === 'activate' && <ActivateTabContent initialCode={urlCode} />}
{activeTab === 'myGifts' && <MyGiftsTabContent />}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}