diff --git a/src/api/branding.ts b/src/api/branding.ts index 72d6ffc..9969d65 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -149,6 +149,11 @@ export const initLogoPreload = () => { } }; +export interface BotStartVideoInfo { + has_video: boolean; + file_id?: string | null; +} + export const brandingApi = { // Get current branding (public, no auth required) getBranding: async (): Promise => { @@ -176,6 +181,29 @@ export const brandingApi = { return response.data; }, + // ── Видео стартового меню бота ──────────────────────────────────────── + // Хранится как Telegram file_id: файл на нашей стороне не сохраняется. + + getBotStartVideo: async (): Promise => { + const response = await apiClient.get('/cabinet/branding/bot-start-video'); + return response.data; + }, + + uploadBotStartVideo: async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post( + '/cabinet/branding/bot-start-video', + formData, + ); + return response.data; + }, + + deleteBotStartVideo: async (): Promise => { + const response = await apiClient.delete('/cabinet/branding/bot-start-video'); + return response.data; + }, + // Delete custom logo (admin only) deleteLogo: async (): Promise => { const response = await apiClient.delete('/cabinet/branding/logo'); diff --git a/src/api/coupons.ts b/src/api/coupons.ts index 6a6820e..fc02445 100644 --- a/src/api/coupons.ts +++ b/src/api/coupons.ts @@ -10,6 +10,8 @@ export interface CouponBatch { period_days: number; coupons_total: number; wholesale_price_kopeks: number; + /** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */ + max_per_user: number; valid_until: string | null; is_revoked: boolean; created_at: string; @@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest { period_days: number; coupons_count: number; wholesale_price_kopeks?: number; + max_per_user?: number; valid_days?: number; } @@ -99,6 +102,12 @@ export const couponsApi = { }, // User: redeem a coupon for the current cabinet user + /** Полное удаление партии (в отличие от revoke, который гасит ссылки) */ + deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => { + const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`); + return response.data; + }, + redeemCoupon: async (token: string): Promise => { const response = await apiClient.post('/cabinet/coupon/redeem', { token }); return response.data; diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts index 533cfdd..0d9c338 100644 --- a/src/api/promoOffers.ts +++ b/src/api/promoOffers.ts @@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse { created_offers: number; user_ids: number[]; target: string | null; + /** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */ notifications_sent: number; notifications_failed: number; + /** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */ + broadcast_id: number | null; + telegram_recipients: number; +} + +export interface PromoOfferSegment { + key: string; + count: number; +} + +export interface PromoOfferSegmentListResponse { + segments: PromoOfferSegment[]; } export interface PromoOfferTemplate { @@ -206,6 +219,12 @@ export const promoOffersApi = { return response.data; }, + // Get recipient counts per target segment + getSegments: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/promo-offers/segments'); + return response.data; + }, + // Get promo offer logs getLogs: async (params?: { limit?: number; diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 4572a0e..cf0e26a 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -13,6 +13,7 @@ import type { PurchasePreview, AppConfig, SbpRecurringInfo, + LavaRecurringInfo, } from '../types'; /** Helper: build query params with optional subscription_id */ @@ -394,6 +395,49 @@ export const subscriptionApi = { return response.data; }, + // ── Recurring (Lava) ────────────────────────────────────────────────── + + getLavaRecurring: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription/lava-recurrent', + withSubId(subscriptionId), + ); + return response.data; + }, + + enableLavaRecurring: async ( + subscriptionId?: number, + ): Promise<{ status: string; redirect_url: string | null }> => { + const response = await apiClient.post( + '/cabinet/subscription/lava-recurrent/enable', + ...bodyWithSubId({}, subscriptionId), + ); + return response.data; + }, + + cancelLavaRecurring: async (subscriptionId?: number): Promise<{ status: string }> => { + const response = await apiClient.post( + '/cabinet/subscription/lava-recurrent/cancel', + ...bodyWithSubId({}, subscriptionId), + ); + return response.data; + }, + + /** + * Оформление подписки на тариф привязкой Lava: первое списание оплачивается + * по возвращённой ссылке и активирует подписку. + */ + purchaseWithLavaRecurring: async ( + tariffId: number, + ): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => { + const response = await apiClient.post( + '/cabinet/subscription/lava-recurrent/purchase', + {}, + { params: { tariff_id: tariffId } }, + ); + return response.data; + }, + // ── Trial ─────────────────────────────────────────────────────────── getTrialInfo: async (): Promise => { diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index ad0771d..d26a990 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -35,6 +35,8 @@ export interface TariffListItem { show_in_gift: boolean; is_daily: boolean; daily_price_kopeks: number; + /** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */ + lava_product_id?: string | null; traffic_limit_gb: number; device_limit: number; tier_level: number; @@ -85,6 +87,8 @@ export interface TariffDetail { // Дневной тариф is_daily: boolean; daily_price_kopeks: number; + /** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */ + lava_product_id?: string | null; // Режим сброса трафика traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка // Внешний сквад Remnawave @@ -124,6 +128,8 @@ export interface TariffCreateRequest { // Дневной тариф is_daily?: boolean; daily_price_kopeks?: number; + // Автопродление Lava: продукт из кабинета Lava + lava_product_id?: string | null; // Режим сброса трафика traffic_reset_mode?: string | null; // Внешний сквад Remnawave @@ -168,6 +174,8 @@ export interface TariffUpdateRequest { // Дневной тариф is_daily?: boolean; daily_price_kopeks?: number; + // Автопродление Lava: продукт из кабинета Lava + lava_product_id?: string | null; // Режим сброса трафика traffic_reset_mode?: string | null; // Внешний сквад Remnawave diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index aec1362..4814b1f 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -15,6 +15,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); + const startVideoInputRef = useRef(null); const [editingName, setEditingName] = useState(false); const [newName, setNewName] = useState(''); @@ -25,6 +26,12 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { queryFn: brandingApi.getBranding, }); + // Видео стартового меню бота: хранится как Telegram file_id + const { data: startVideo } = useQuery({ + queryKey: ['bot-start-video'], + queryFn: brandingApi.getBotStartVideo, + }); + const { data: fullscreenSettings } = useQuery({ queryKey: ['fullscreen-enabled'], queryFn: brandingApi.getFullscreenEnabled, @@ -100,6 +107,29 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { }, }); + const uploadStartVideoMutation = useMutation({ + mutationFn: brandingApi.uploadBotStartVideo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bot-start-video'] }); + }, + }); + + const deleteStartVideoMutation = useMutation({ + mutationFn: brandingApi.deleteBotStartVideo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bot-start-video'] }); + }, + }); + + const handleStartVideoUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + uploadStartVideoMutation.mutate(file); + } + // Сбрасываем input, чтобы повторный выбор того же файла снова сработал + e.target.value = ''; + }; + const handleLogoUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { @@ -211,6 +241,57 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { + {/* Видео стартового меню бота */} +
+

+ {t('admin.settings.botStartVideo')} +

+

{t('admin.settings.botStartVideoDesc')}

+ +
+ + + + {startVideo?.has_video && ( + + )} + + + {startVideo?.has_video + ? t('admin.settings.botStartVideoActive') + : t('admin.settings.botStartVideoNone')} + +
+ + {uploadStartVideoMutation.isError && ( +
+ {t('admin.settings.botStartVideoError')} +
+ )} +
+ {/* Animated Background Editor */}
diff --git a/src/components/admin/ButtonsTab.tsx b/src/components/admin/ButtonsTab.tsx index c7b05d8..6869d3e 100644 --- a/src/components/admin/ButtonsTab.tsx +++ b/src/components/admin/ButtonsTab.tsx @@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { buttonStylesApi, - ButtonStylesConfig, + type ButtonStylesConfig, DEFAULT_BUTTON_STYLES, BUTTON_SECTIONS, - ButtonSection, + type ButtonSection, BOT_LOCALES, } from '../../api/buttonStyles'; import { ChevronDownIcon } from '@/components/icons'; @@ -18,7 +18,10 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default'; const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [ { value: 'default', colorClass: 'bg-dark-500' }, - { value: 'primary', colorClass: 'bg-accent-500' }, + // 'primary' — синяя кнопка Telegram Bot API: свотч фиксированный телеграм-синий, + // а не bg-accent-500 — акцент темы кабинета перекрашивается (например, в оранжевый) + // и не имеет отношения к цвету реальной кнопки в боте. + { value: 'primary', colorClass: 'bg-[#54a9eb]' }, { value: 'success', colorClass: 'bg-success-500' }, { value: 'danger', colorClass: 'bg-error-500' }, ]; @@ -228,7 +231,7 @@ export function ButtonsTab() { ? 'bg-success-500 text-white' : cfg.style === 'danger' ? 'bg-error-500 text-white' - : 'bg-accent-500 text-on-accent' + : 'bg-[#54a9eb] text-white' }`} > {t(`admin.buttons.styles.${cfg.style}`)} diff --git a/src/components/broadcasts/BroadcastDeliveryStats.tsx b/src/components/broadcasts/BroadcastDeliveryStats.tsx new file mode 100644 index 0000000..6bd7c29 --- /dev/null +++ b/src/components/broadcasts/BroadcastDeliveryStats.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from 'react-i18next'; +import { StatCard } from '@/components/stats'; +import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons'; +import { isBroadcastInFlight } from '../../utils/broadcastStatus'; + +const statusConfig: Record = { + queued: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.queued', + }, + in_progress: { + bg: 'bg-accent-500/20', + text: 'text-accent-400', + labelKey: 'admin.broadcasts.status.inProgress', + }, + completed: { + bg: 'bg-success-500/20', + text: 'text-success-400', + labelKey: 'admin.broadcasts.status.completed', + }, + partial: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.partial', + }, + failed: { + bg: 'bg-error-500/20', + text: 'text-error-400', + labelKey: 'admin.broadcasts.status.failed', + }, + cancelled: { + bg: 'bg-dark-500/20', + text: 'text-dark-400', + labelKey: 'admin.broadcasts.status.cancelled', + }, + cancelling: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.cancelling', + }, +}; + +export function BroadcastStatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); + const config = statusConfig[status] || statusConfig.queued; + return ( + + {t(config.labelKey)} + + ); +} + +export interface BroadcastDeliveryStatsProps { + status: string; + progressPercent: number; + totalCount: number; + sentCount: number; + blockedCount: number; + failedCount: number; +} + +/** + * Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько + * не доехало. Используется и на странице рассылки, и на отправке промопредложения. + */ +export function BroadcastDeliveryStats({ + status, + progressPercent, + totalCount, + sentCount, + blockedCount, + failedCount, +}: BroadcastDeliveryStatsProps) { + const { t } = useTranslation(); + + return ( +
+ {isBroadcastInFlight(status) && ( +
+
+ {t('admin.broadcasts.progress')} + {progressPercent.toFixed(1)}% +
+
+
+
+
+ )} + +
+ } + tone="neutral" + /> + } + tone="success" + /> + } + tone="warning" + /> + } + tone="error" + /> +
+
+ ); +} + +export default BroadcastDeliveryStats; diff --git a/src/components/connection/TvQuickConnect.tsx b/src/components/connection/TvQuickConnect.tsx index 56eb4e4..cdb7e09 100644 --- a/src/components/connection/TvQuickConnect.tsx +++ b/src/components/connection/TvQuickConnect.tsx @@ -6,6 +6,7 @@ import { retrieveLaunchParams, } from '@telegram-apps/sdk-react'; import { blockButtonClass } from './blocks/buttonStyles'; +import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner'; const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); @@ -19,24 +20,12 @@ function isTelegramMobile(): boolean { } const HAPP_TV_API = 'https://check.happ.su/sendtv'; -const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js'; interface Props { subscriptionUrl: string; isLight: boolean; } -interface Html5QrcodeInstance { - start: ( - cameraIdOrConfig: { facingMode: string } | string, - config: { fps: number; qrbox: { width: number; height: number } }, - onSuccess: (decoded: string) => void, - onError: () => void, - ) => Promise; - stop: () => Promise; - clear: () => void; -} - export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { const { t } = useTranslation(); const [code, setCode] = useState(''); @@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { return; } - // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode - type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; - const w = window as WindowWithHtml5; - if (!w.Html5Qrcode) { - const script = document.createElement('script'); - script.src = HTML5_QRCODE_CDN; - document.head.appendChild(script); - await new Promise((resolve, reject) => { - script.onload = () => resolve(); - script.onerror = () => reject(); - }).catch(() => undefined); - } - if (!w.Html5Qrcode) { + // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode. + // Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой + // целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете. + const Html5Qrcode = await loadHtml5Qrcode(); + if (!Html5Qrcode) { showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); return; } setScanning(true); - const scanner = new w.Html5Qrcode('tv-qr-reader'); + const scanner = new Html5Qrcode('tv-qr-reader'); scannerRef.current = scanner; const config = { fps: 10, qrbox: { width: 220, height: 220 } }; try { diff --git a/src/components/subscription/purchase/TariffPurchaseForm.tsx b/src/components/subscription/purchase/TariffPurchaseForm.tsx index d49a918..5dad89c 100644 --- a/src/components/subscription/purchase/TariffPurchaseForm.tsx +++ b/src/components/subscription/purchase/TariffPurchaseForm.tsx @@ -8,6 +8,7 @@ import { useCurrency } from '../../../hooks/useCurrency'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import { usePlatform } from '../../../platform'; import { openPaymentUrl } from '../../../utils/openPaymentUrl'; +import { getMonthlyPriceKopeks } from '../../../utils/pricing'; import InsufficientBalancePrompt from '../../InsufficientBalancePrompt'; import type { Tariff, TariffPeriod } from '../../../types'; @@ -35,6 +36,8 @@ export interface TariffPurchaseFormProps { balanceKopeks: number | undefined; /** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */ sbpPurchaseEnabled?: boolean; + /** Оформление привязкой Lava доступно — показать вторую CTA. */ + lavaPurchaseEnabled?: boolean; onBack: () => void; } @@ -43,6 +46,7 @@ export function TariffPurchaseForm({ subscriptionId, balanceKopeks, sbpPurchaseEnabled = false, + lavaPurchaseEnabled = false, onBack, }: TariffPurchaseFormProps) { const { t } = useTranslation(); @@ -142,6 +146,47 @@ export function TariffPurchaseForm({ ); + const lavaPurchaseMutation = useMutation({ + mutationFn: () => subscriptionApi.purchaseWithLavaRecurring(tariff.id), + onSuccess: (data) => { + if (data.redirect_url) { + openPaymentUrl(data.redirect_url, platform, openLink); + } + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + queryClient.invalidateQueries({ queryKey: ['lava-recurring', data.subscription_id] }); + navigate('/subscriptions', { replace: true }); + }, + }); + + const lavaPurchaseButton = lavaPurchaseEnabled && ( + <> + +
+ {t('subscription.lavaRecurring.purchaseHint')} +
+ {lavaPurchaseMutation.isError && ( +
+ {getErrorMessage(lavaPurchaseMutation.error)} +
+ )} + + ); + // Smooth scroll the form into view when first mounted. useEffect(() => { if (ref.current) { @@ -240,6 +285,7 @@ export function TariffPurchaseForm({ {sbpPurchaseButton} + {lavaPurchaseButton} {purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && ( @@ -279,10 +325,7 @@ export function TariffPurchaseForm({ const displayDiscount = promoPeriod.percent; const displayOriginal = promoPeriod.original; const displayPrice = promoPeriod.price; - const displayPerMonth = - displayPrice !== period.price_kopeks - ? Math.round(displayPrice / Math.max(1, period.days / 30)) - : period.price_per_month_kopeks; + const displayPerMonth = getMonthlyPriceKopeks(displayPrice, period.days); return (
-
- {formatPrice(displayPerMonth)}/{t('subscription.month')} -
+ {displayPerMonth !== null && ( +
+ {formatPrice(displayPerMonth)}/{t('subscription.month')} +
+ )} ); })} @@ -666,6 +711,7 @@ export function TariffPurchaseForm({ {sbpPurchaseButton} + {lavaPurchaseButton} ); })()} diff --git a/src/locales/en.json b/src/locales/en.json index 6824c06..c5af0e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -701,6 +701,33 @@ "purchaseButton": "⚡ Subscribe with SBP auto-payment", "purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence." }, + "lavaRecurring": { + "title": "Lava auto-renewal", + "connect": "Connect", + "payFirst": "Pay the first invoice", + "cancel": "Disable", + "confirmCancel": "Disable Lava auto-renewal? The subscription will no longer renew automatically.", + "cancelled": "Lava auto-renewal disabled", + "enableError": "Could not enable Lava auto-renewal", + "cancelError": "Could not disable Lava auto-renewal", + "statusPending": "Waiting for the first invoice to be paid", + "statusActive": "Active", + "statusPastDue": "Payment failed — please try again", + "nextCharge": "next charge on {{date}}", + "amountPerPeriod": "{{amount}} ₽ {{period}}", + "amountPerDays": "{{amount}} ₽ every {{days}} days", + "period": { + "day": "daily", + "week": "weekly", + "month": "monthly", + "quarter": "quarterly", + "halfYear": "every six months", + "year": "yearly" + }, + "autopayHint": "The subscription will renew automatically via Lava", + "purchaseButton": "⚡ Subscribe with Lava auto-renewal", + "purchaseHint": "The first invoice is paid via a Lava link; renewals are then charged automatically on the product’s schedule." + }, "backToList": "Back to subscriptions", "confirmDelete": "Delete subscription?", "dailyAutoCharge": "Daily auto-charge", @@ -1930,7 +1957,7 @@ }, "broadcasts": { "title": "Broadcasts", - "subtitle": "History and management", + "subtitle": "Mass sending of messages to users", "create": "Create Broadcast", "empty": "No broadcasts", "selectFilter": "Select audience", @@ -2293,7 +2320,15 @@ "offlineConv": "Offline Conversions", "apiKey": "API Key", "legalFooter": "Legal Footer", - "legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page" + "legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page", + "botStartVideo": "Bot start-menu video", + "botStartVideoDesc": "The bot attaches this video to the /start message instead of the logo image. The file is uploaded to Telegram once — we only keep its identifier.", + "botStartVideoUpload": "Upload video", + "botStartVideoReplace": "Replace video", + "botStartVideoRemove": "Remove", + "botStartVideoActive": "Video is set", + "botStartVideoNone": "No video — the menu uses the logo image", + "botStartVideoError": "Could not upload the video" }, "bulkActions": { "title": "Bulk Actions", @@ -2740,7 +2775,9 @@ "periodsRequired": "Add at least one period", "dailyPriceRequired": "Enter a price per day greater than 0", "trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)" - } + }, + "lavaProductLabel": "Lava product (auto-renewal)", + "lavaProductDesc": "Product UUID from the Lava dashboard: price and charge period are configured there. Empty — auto-renewal is unavailable for this tariff." }, "servers": { "title": "Squad Management", @@ -3131,7 +3168,9 @@ "validDaysHint": "0 or empty — perpetual", "create": "Create batch", "creating": "Creating…", - "cancel": "Cancel" + "cancel": "Cancel", + "maxPerUser": "Per-user limit", + "maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests." }, "validation": { "nameRequired": "Enter the batch name", @@ -3156,7 +3195,8 @@ "validUntil": "Valid until", "perpetual": "Perpetual", "createdAt": "Created", - "linksTitle": "Active links ({{count}})" + "linksTitle": "Active links ({{count}})", + "maxPerUser": "Per user" }, "revoke": { "button": "Revoke unredeemed ({{count}})", @@ -3169,7 +3209,16 @@ "errors": { "loadFailed": "Batch not found", "createFailed": "Failed to create the batch", - "revokeFailed": "Failed to revoke the batch" + "revokeFailed": "Failed to revoke the batch", + "deleteFailed": "Could not delete the batch" + }, + "delete": { + "button": "🗑 Delete batch", + "confirmTitle": "Delete the batch?", + "confirmText": "{{count}} coupons will be deleted along with the batch itself.", + "redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.", + "confirm": "Delete", + "success": "Batch deleted ({{count}} coupons)" } }, "promocodes": { @@ -3807,7 +3856,9 @@ "sendError": "Failed to send offer", "notificationsSent": "Notifications sent", "offersCreated": "Offers created", - "notificationsFailed": "(failed: {{count}})" + "notificationsFailed": "(failed: {{count}})", + "deliveryTitle": "Telegram delivery", + "openAsBroadcast": "Open as broadcast" }, "notFound": "Template not found", "noActiveTemplates": "No active templates", @@ -5453,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 224e3ec..6e805a5 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -276,7 +276,7 @@ "traffic": "ترافیک", "devices": "دستگاه‌ها", "expiredDate": "منقضی شده", - "activeUntil": "منقضی شده — تا {{date}}" + "activeUntil": "فعال تا" }, "deviceLimitReached": "برای افزودن دستگاه جدید، دستگاه‌های فعلی را قطع کنید", "suspended": { @@ -287,9 +287,9 @@ "showAll": "نمایش همه", "subscriptions": "اشتراک‌ها", "connectDevice": "اتصال دستگاه", - "devicesConnectedUnlimited": "{{count}} دستگاه متصل", + "devicesConnectedUnlimited": "{{used}} دستگاه متصل · نامحدود", "devicesOfMax": "{{used}} از {{max}}", - "maxUsage": "حداکثر", + "maxUsage": "حداکثر {{amount}}", "remaining": "باقی‌مانده", "stats": { "balance": "موجودی", @@ -298,9 +298,9 @@ "tariff": "تعرفه", "trafficUsageTitle": "مصرف ترافیک", "trialOffer": { - "freeDesc": "{{days}} روز رایگان آزمایش کنید — بدون پرداخت", + "freeDesc": "وی‌پی‌ان ما را رایگان امتحان کنید: بدون هیچ تعهدی", "freeTitle": "دوره آزمایشی رایگان", - "paidDesc": "{{days}} روز آزمایشی فقط با {{price}}", + "paidDesc": "با یک اشتراک آزمایشی شروع کنید", "paidTitle": "دوره آزمایشی پرداختی" }, "unlimited": "نامحدود", @@ -458,7 +458,7 @@ "newDeviceLimit": "محدودیت جدید دستگاه", "reduce": "کاهش", "reduceDevices": "کاهش تعداد دستگاه‌ها", - "reduceDevicesDescription": "محدودیت دستگاه‌های خود را تنظیم کنید. در صورت لزوم ابتدا دستگاه‌های اضافه را قطع کنید", + "reduceDevicesDescription": "می‌توانید تعداد دستگاه‌ها را تا حداقل مجاز در تعرفه کاهش دهید", "reduceDevicesTitle": "کاهش تعداد دستگاه‌ها", "reduceUnavailable": "در حال حاضر کاهش تعداد دستگاه‌ها امکان‌پذیر نیست", "reducing": "در حال کاهش…" @@ -600,13 +600,40 @@ "purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP", "purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند." }, + "lavaRecurring": { + "title": "تمدید خودکار Lava", + "connect": "اتصال", + "payFirst": "پرداخت صورتحساب اول", + "cancel": "غیرفعال‌سازی", + "confirmCancel": "تمدید خودکار Lava غیرفعال شود؟ اشتراک دیگر به‌طور خودکار تمدید نمی‌شود.", + "cancelled": "تمدید خودکار Lava غیرفعال شد", + "enableError": "فعال‌سازی تمدید خودکار Lava ناموفق بود", + "cancelError": "غیرفعال‌سازی تمدید خودکار Lava ناموفق بود", + "statusPending": "در انتظار پرداخت صورتحساب اول", + "statusActive": "فعال", + "statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید", + "nextCharge": "برداشت بعدی {{date}}", + "amountPerPeriod": "{{amount}} ₽ {{period}}", + "amountPerDays": "هر {{days}} روز {{amount}} ₽", + "period": { + "day": "روزانه", + "week": "هفتگی", + "month": "ماهانه", + "quarter": "هر سه ماه", + "halfYear": "هر شش ماه", + "year": "سالانه" + }, + "autopayHint": "اشتراک به‌طور خودکار از طریق Lava تمدید می‌شود", + "purchaseButton": "⚡ ثبت‌نام با تمدید خودکار Lava", + "purchaseHint": "صورتحساب اول از طریق لینک Lava پرداخت می‌شود؛ سپس تمدیدها بر اساس دوره محصول به‌طور خودکار کسر می‌شوند." + }, "backToList": "بازگشت به فهرست اشتراک‌ها", "confirmDelete": "اشتراک حذف شود؟", "cta": { "renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید", - "activeHint": "اشتراک فعال است — می‌توانید دستگاه‌ها را متصل کرده و از VPN استفاده کنید", - "expiredHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید", - "trialHint": "شما در دوره آزمایشی هستید — برای دسترسی به همه امکانات به تعرفه پرداختی ارتقا دهید" + "activeHint": "تمدید و تغییر تعرفه", + "expiredHint": "یک تعرفه انتخاب و پرداخت کنید", + "trialHint": "ترافیک و دستگاه بیشتر" }, "dailyAutoCharge": "برداشت خودکار روزانه", "defaultName": "اشتراک", @@ -626,16 +653,16 @@ "statusTrial": "آزمایشی", "daysShort": "روز", "expiredBanner": { - "selectTariff": "تعرفه را انتخاب کنید", + "selectTariff": "اشتراک شما منقضی شده است. برای بازیابی دسترسی به VPN، یک تعرفه را از پایین انتخاب کنید.", "title": "اشتراک منقضی شده است" }, "trialInfo": { - "description": "{{days}} روز سرویس ما را رایگان امتحان کنید", - "remaining": "{{count}} روز باقی‌مانده", + "description": "شما در حال استفاده از نسخهٔ آزمایشی سرویس هستید. برای دسترسی کامل، یک تعرفه را در پایین انتخاب کنید.", + "remaining": "باقی‌مانده", "title": "دوره آزمایشی" }, "trialUpgrade": { - "description": "برای دسترسی به همه امکانات و ادامه استفاده به اشتراک پرداختی ارتقا دهید", + "description": "دوره آزمایشی شما به‌زودی به پایان می‌رسد. برای ادامه استفاده از VPN بدون محدودیت، یک تعرفه مناسب انتخاب کنید.", "title": "ارتقای اشتراک" } }, @@ -1008,7 +1035,7 @@ "noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.", "selectSubscription": "ابتدا یک اشتراک انتخاب کنید" }, - "payWithStars": "پرداخت {stars} Stars", + "payWithStars": "پرداخت Stars", "processingPayment": "در حال پردازش...", "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", @@ -1564,7 +1591,7 @@ }, "broadcasts": { "title": "نشریات", - "subtitle": "تاریخچه و مدیریت", + "subtitle": "ارسال گروهی پیام به کاربران", "create": "ایجاد پیام", "empty": "هیچ نشریه‌ای نیست", "selectFilter": "انتخاب مخاطبین", @@ -1652,46 +1679,46 @@ "telegramSection": "ارسال انبوه تلگرام" }, "pinnedMessages": { - "title": "Pinned Messages", - "subtitle": "Manage pinned messages for users", - "create": "Create", - "empty": "No pinned messages", - "content": "Message text", - "contentPlaceholder": "Enter pinned message text...", - "noContent": "No text", - "media": "Media file", - "addMedia": "Add media", - "uploading": "Uploading...", - "settings": "Settings", - "sendBeforeMenu": "Send before menu", - "sendOnEveryStart": "Send on every start", - "broadcast": "Broadcast", - "broadcastOnCreate": "Broadcast on create", - "broadcastConfirm": "Broadcast pinned message to all active users?", - "broadcastToAll": "Broadcast to all", - "activate": "Activate", - "deactivate": "Deactivate", - "unpin": "Unpin", - "unpinAll": "Unpin for everyone", - "unpinConfirm": "Unpin message for all users?", - "delete": "Delete", - "deleteConfirm": "Delete this pinned message?", - "active": "Active", - "inactive": "Inactive", - "prev": "Previous", - "next": "Next", - "createSuccess": "Pinned message created", - "updateSuccess": "Message updated", - "deleteSuccess": "Message deleted", - "activateSuccess": "Message activated", - "deactivateSuccess": "Message deactivated", - "broadcastSuccess": "Broadcast completed", - "unpinSuccess": "Messages unpinned", - "sentCount": "Sent", - "failedCount": "Failed", - "unpinnedCount": "Unpinned", - "editMessage": "Edit", - "cantDeleteActive": "Cannot delete active message" + "title": "پیام‌های سنجاق‌شده", + "subtitle": "مدیریت پیام‌های سنجاق‌شده برای کاربران", + "create": "ایجاد", + "empty": "هیچ پیام سنجاق‌شده‌ای وجود ندارد", + "content": "متن پیام", + "contentPlaceholder": "متن پیام سنجاق‌شده را وارد کنید...", + "noContent": "بدون متن", + "media": "فایل رسانه", + "addMedia": "افزودن رسانه", + "uploading": "در حال بارگذاری...", + "settings": "تنظیمات", + "sendBeforeMenu": "ارسال قبل از منو", + "sendOnEveryStart": "ارسال در هر بار شروع", + "broadcast": "ارسال همگانی", + "broadcastOnCreate": "ارسال همگانی هنگام ایجاد", + "broadcastConfirm": "پیام سنجاق‌شده برای همه کاربران فعال ارسال شود؟", + "broadcastToAll": "ارسال به همه", + "activate": "فعال‌سازی", + "deactivate": "غیرفعال‌سازی", + "unpin": "برداشتن سنجاق", + "unpinAll": "برداشتن سنجاق برای همه", + "unpinConfirm": "سنجاق پیام برای همه کاربران برداشته شود؟", + "delete": "حذف", + "deleteConfirm": "این پیام سنجاق‌شده حذف شود؟", + "active": "فعال", + "inactive": "غیرفعال", + "prev": "قبلی", + "next": "بعدی", + "createSuccess": "پیام سنجاق‌شده ایجاد شد", + "updateSuccess": "پیام به‌روزرسانی شد", + "deleteSuccess": "پیام حذف شد", + "activateSuccess": "پیام فعال شد", + "deactivateSuccess": "پیام غیرفعال شد", + "broadcastSuccess": "ارسال همگانی تکمیل شد", + "unpinSuccess": "سنجاق پیام‌ها برداشته شد", + "sentCount": "ارسال‌شده", + "failedCount": "ناموفق", + "unpinnedCount": "سنجاق‌برداشته", + "editMessage": "ویرایش", + "cantDeleteActive": "نمی‌توان پیام فعال را حذف کرد" }, "channelSubscriptions": { "title": "کانال‌های اجباری", @@ -1956,7 +1983,15 @@ "offlineConv": "تبدیل‌های آفلاین", "apiKey": "کلید API", "legalFooter": "پاورقی حقوقی", - "legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود" + "legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود", + "botStartVideo": "ویدیوی منوی شروع ربات", + "botStartVideoDesc": "ربات این ویدیو را به‌جای تصویر لوگو به پیام /start پیوست می‌کند. فایل یک‌بار در تلگرام بارگذاری می‌شود و ما فقط شناسه آن را نگه می‌داریم.", + "botStartVideoUpload": "بارگذاری ویدیو", + "botStartVideoReplace": "جایگزینی ویدیو", + "botStartVideoRemove": "حذف", + "botStartVideoActive": "ویدیو تنظیم شده است", + "botStartVideoNone": "ویدیویی تنظیم نشده — منو از تصویر لوگو استفاده می‌کند", + "botStartVideoError": "بارگذاری ویدیو ناموفق بود" }, "buttons": { "color": "رنگ دکمه", @@ -2005,7 +2040,7 @@ "builtinButtons": "بخش‌های داخلی", "resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.", "buttonTextPlaceholder": "متن دکمه", - "customLabelsHint": "متن دکمه برای هر زبان ربات", + "customLabelsHint": "خالی = عنوان پیش‌فرض", "moveUp": "انتقال به بالا", "moveDown": "انتقال به پایین", "openIn": "باز کردن در", @@ -2278,7 +2313,9 @@ "periodsRequired": "حداقل یک دوره اضافه کنید", "dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید", "trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)" - } + }, + "lavaProductLabel": "محصول Lava (تمدید خودکار)", + "lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم می‌شود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست." }, "servers": { "title": "مدیریت اسکوادها", @@ -2625,10 +2662,10 @@ "programEnabled": "برنامه معرفی فعال است", "programEnabledDesc": "تمام عملکرد ارجاع را فعال می‌کند", "requisitesText": "متن اطلاعات حساب", - "requisitesTextDesc": "در فرم درخواست همکاری به کاربر نمایش داده می‌شود", + "requisitesTextDesc": "متن سفارشی برای توضیح به کاربران که هنگام برداشت چه اطلاعات پرداختی را وارد کنند", "requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...", "withdrawalEnabled": "برداشت مجاز است", - "withdrawalEnabledDesc": "به کاربران اجازه می‌دهد درخواست برداشت بدهند" + "withdrawalEnabledDesc": "اجازه دادن به همکاران برای درخواست برداشت موجودی معرفی" }, "settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود", "settingsSection": { @@ -3305,7 +3342,9 @@ "notificationsFailed": "(ناموفق: {{count}})", "sendError": "ارسال پیشنهاد ناموفق بود", "notificationsSent": "اعلان‌های ارسال شده", - "offersCreated": "پیشنهادهای ایجاد شده" + "offersCreated": "پیشنهادهای ایجاد شده", + "deliveryTitle": "تحویل در تلگرام", + "openAsBroadcast": "نمایش به‌عنوان ارسال گروهی" }, "notFound": "قالب یافت نشد", "noActiveTemplates": "قالب فعالی وجود ندارد", @@ -3398,7 +3437,7 @@ "usersOnlineCount": "{{count}} آنلاین" }, "overview": { - "bandwidth": "پهنای باند لحظه‌ای", + "bandwidth": "ترافیک ورودی‌ها", "connections": "اتصالات", "cpu": "هسته‌های CPU", "download": "دانلود", @@ -4940,40 +4979,48 @@ }, "activateButton": "فعال‌سازی", "activateCodePlaceholder": "کد هدیه را وارد کنید", - "activatedBy": "توسط {{name}} فعال شد", + "activatedBy": "توسط {{username}} فعال شد", "activateDescription": "برای فعال‌سازی اشتراک، کد هدیه دریافتی را وارد کنید", "activatedGiftsTitle": "هدایای فعال‌شده", "activateError": "فعال‌سازی هدیه ناموفق بود", "activateSelfError": "نمی‌توانید هدیه خودتان را فعال کنید", "activateSuccess": "هدیه فعال شد!", - "activateSuccessDesc": "اشتراک به حساب شما افزوده شد", + "activateSuccessDesc": "{{tariff}}: {{days}} روز به اشتراک شما اضافه شد", "activateTitle": "فعال‌سازی هدیه", "activeGiftsTitle": "هدایای فعال", "codeLabel": "کد هدیه", "codeReadyTitle": "کد هدیه شما آماده است", "copyMessage": "کپی پیام", "daysShort": "روز", - "deviceCount": "تعداد دستگاه‌ها", + "deviceCount": "{{count}} دستگاه", "devicesShort": "دستگاه", "gbShort": "گیگابایت", "myGiftsEmpty": "هنوز هدیه‌ای ندارید", - "myGiftsEmptyDesc": "هدایایی که ارسال یا دریافت می‌کنید اینجا نمایش داده می‌شوند", + "myGiftsEmptyDesc": "برای یک دوست هدیه بخرید یا کد دریافتی را فعال کنید", "pageTitle": "هدایا", "receivedGiftsTitle": "هدایای دریافت‌شده", "selectPeriod": "دوره را انتخاب کنید", "selectTariff": "تعرفه را انتخاب کنید", - "sentTo": "به {{name}} ارسال شد", + "sentTo": "به {{recipient}} ارسال شد", "shareGift": "به اشتراک گذاشتن هدیه", - "shareModalActivateVia": "از طریق فعال‌سازی", + "shareModalActivateVia": "فعال‌سازی از طریق ربات:", "shareModalActivateViaCabinet": "از طریق کابین", - "shareText": "برای شما هدیه‌ای از Bedolaga VPN دارم: {{code}}", + "shareText": "برای تو یک هدیهٔ Bedolaga VPN دارم، از همین‌جا فعالش کن:", "shareToastCopied": "لینک کپی شد", "statusActivated": "فعال شده", "statusAvailable": "قابل فعال‌سازی", "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 1cfef4f..9dd3f21 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -720,6 +720,33 @@ "purchaseButton": "⚡ Оформить с автооплатой СБП", "purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа." }, + "lavaRecurring": { + "title": "Автопродление Lava", + "connect": "Подключить", + "payFirst": "Оплатить первый счёт", + "cancel": "Отключить", + "confirmCancel": "Отключить автопродление Lava? Подписка больше не будет продлеваться автоматически.", + "cancelled": "Автопродление Lava отключено", + "enableError": "Не удалось подключить автопродление Lava", + "cancelError": "Не удалось отменить автопродление Lava", + "statusPending": "Ожидает оплаты первого счёта", + "statusActive": "Активно", + "statusPastDue": "Платёж не прошёл — попробуйте снова", + "nextCharge": "следующее списание {{date}}", + "amountPerPeriod": "{{amount}} ₽ {{period}}", + "amountPerDays": "{{amount}} ₽ раз в {{days}} дн.", + "period": { + "day": "ежедневно", + "week": "еженедельно", + "month": "ежемесячно", + "quarter": "раз в квартал", + "halfYear": "раз в полгода", + "year": "ежегодно" + }, + "autopayHint": "Подписка будет автоматически продлеваться через Lava", + "purchaseButton": "⚡ Оформить с автооплатой Lava", + "purchaseHint": "Первое списание оплачивается по ссылке Lava; дальше продления списываются автоматически по периоду продукта." + }, "backToList": "К списку подписок", "confirmDelete": "Удалить подписку?", "dailyAutoCharge": "Ежедневное списание", @@ -2796,7 +2823,15 @@ "offlineConv": "Офлайн-конверсии", "apiKey": "API-ключ", "legalFooter": "Юридический футер", - "legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа" + "legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа", + "botStartVideo": "Видео стартового меню бота", + "botStartVideoDesc": "Бот прикрепит это видео к сообщению /start вместо картинки-логотипа. Файл разово отправляется в Telegram — храним только его идентификатор.", + "botStartVideoUpload": "Загрузить видео", + "botStartVideoReplace": "Заменить видео", + "botStartVideoRemove": "Удалить", + "botStartVideoActive": "Видео подключено", + "botStartVideoNone": "Видео не задано — меню с картинкой-логотипом", + "botStartVideoError": "Не удалось загрузить видео" }, "buttons": { "color": "Цвет кнопки", @@ -3129,7 +3164,9 @@ "periodsRequired": "Добавьте хотя бы один период", "dailyPriceRequired": "Укажите цену за день больше 0", "trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)" - } + }, + "lavaProductLabel": "Продукт Lava (автопродление)", + "lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно." }, "servers": { "title": "Управление сквадами", @@ -3523,7 +3560,9 @@ "validDaysHint": "0 или пусто — бессрочно", "create": "Создать партию", "creating": "Создание…", - "cancel": "Отмена" + "cancel": "Отмена", + "maxPerUser": "Лимит на пользователя", + "maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1." }, "validation": { "nameRequired": "Укажите название партии", @@ -3548,7 +3587,8 @@ "validUntil": "Действует до", "perpetual": "Бессрочно", "createdAt": "Создана", - "linksTitle": "Активные ссылки ({{count}} шт.)" + "linksTitle": "Активные ссылки ({{count}} шт.)", + "maxPerUser": "На пользователя" }, "revoke": { "button": "Отозвать непогашенные ({{count}})", @@ -3561,7 +3601,16 @@ "errors": { "loadFailed": "Партия не найдена", "createFailed": "Не удалось создать партию", - "revokeFailed": "Не удалось отозвать партию" + "revokeFailed": "Не удалось отозвать партию", + "deleteFailed": "Не удалось удалить партию" + }, + "delete": { + "button": "🗑 Удалить партию", + "confirmTitle": "Удалить партию?", + "confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.", + "redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.", + "confirm": "Удалить", + "success": "Партия удалена (купонов: {{count}})" } }, "promocodes": { @@ -4208,7 +4257,9 @@ "sendError": "Не удалось отправить предложение", "notificationsSent": "Отправлено уведомлений", "offersCreated": "Создано офферов", - "notificationsFailed": "(не отправлено: {{count}})" + "notificationsFailed": "(не отправлено: {{count}})", + "deliveryTitle": "Доставка в Telegram", + "openAsBroadcast": "Открыть как рассылку" }, "notFound": "Шаблон не найден", "noActiveTemplates": "Нет активных шаблонов", @@ -6007,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 7ebb9af..1e85997 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -20,7 +20,7 @@ "delete": "删除", "currency": "¥", "copy": "复制", - "all": "All", + "all": "全部", "add": "添加", "refresh": "刷新", "retry": "重试", @@ -276,7 +276,7 @@ "traffic": "流量", "devices": "设备", "expiredDate": "过期时间", - "activeUntil": "已过期 — 至 {{date}}" + "activeUntil": "有效期至" }, "deviceLimitReached": "断开设备以添加新设备", "suspended": { @@ -287,9 +287,9 @@ "showAll": "全部显示", "subscriptions": "订阅", "connectDevice": "连接设备", - "devicesConnectedUnlimited": "已连接 {{count}} 台设备", + "devicesConnectedUnlimited": "已连接 {{used}} 台设备 · 无限制", "devicesOfMax": "{{used}} / {{max}}", - "maxUsage": "最大", + "maxUsage": "最大 {{amount}}", "remaining": "剩余", "stats": { "balance": "余额", @@ -298,9 +298,9 @@ "tariff": "套餐", "trafficUsageTitle": "流量使用", "trialOffer": { - "freeDesc": "免费试用 {{days}} 天 — 无需付款", + "freeDesc": "免费试用我们的 VPN:无需任何承诺", "freeTitle": "免费试用", - "paidDesc": "{{days}} 天试用,仅需 {{price}}", + "paidDesc": "从试用订阅开始吧", "paidTitle": "付费试用" }, "unlimited": "无限", @@ -458,7 +458,7 @@ "newDeviceLimit": "新设备限制", "reduce": "减少", "reduceDevices": "减少设备数量", - "reduceDevicesDescription": "调整您的设备限制。如有需要,请先断开多余设备的连接", + "reduceDevicesDescription": "您可以将设备数量减少至套餐的最低限制", "reduceDevicesTitle": "减少设备数量", "reduceUnavailable": "当前无法减少设备数量", "reducing": "正在减少…" @@ -600,13 +600,40 @@ "purchaseButton": "⚡ 通过SBP自动扣款订阅", "purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。" }, + "lavaRecurring": { + "title": "Lava 自动续订", + "connect": "连接", + "payFirst": "支付首期账单", + "cancel": "关闭", + "confirmCancel": "要关闭 Lava 自动续订吗?订阅将不再自动续订。", + "cancelled": "已关闭 Lava 自动续订", + "enableError": "无法开启 Lava 自动续订", + "cancelError": "无法关闭 Lava 自动续订", + "statusPending": "等待支付首期账单", + "statusActive": "已启用", + "statusPastDue": "扣款失败 — 请重试", + "nextCharge": "下次扣款 {{date}}", + "amountPerPeriod": "{{amount}} ₽ {{period}}", + "amountPerDays": "每 {{days}} 天 {{amount}} ₽", + "period": { + "day": "每天", + "week": "每周", + "month": "每月", + "quarter": "每季度", + "halfYear": "每半年", + "year": "每年" + }, + "autopayHint": "订阅将通过 Lava 自动续订", + "purchaseButton": "⚡ 使用 Lava 自动续订下单", + "purchaseHint": "首期账单通过 Lava 链接支付;之后按产品周期自动扣款续订。" + }, "backToList": "返回订阅列表", "confirmDelete": "删除订阅?", "cta": { "renewHint": "订阅已过期 — 续订以继续使用", - "activeHint": "订阅有效 — 您可以连接设备并使用 VPN", - "expiredHint": "订阅已过期 — 续订以继续使用", - "trialHint": "您正在试用中 — 升级到付费套餐以解锁所有功能" + "activeHint": "续费和更换套餐", + "expiredHint": "选择套餐并付款", + "trialHint": "更多流量和设备" }, "dailyAutoCharge": "每日自动扣费", "defaultName": "订阅", @@ -626,16 +653,16 @@ "statusTrial": "试用", "daysShort": "天", "expiredBanner": { - "selectTariff": "选择套餐", + "selectTariff": "您的订阅已到期。请在下方选择套餐,以恢复 VPN 访问。", "title": "订阅已过期" }, "trialInfo": { - "description": "免费试用我们的服务 {{days}} 天", - "remaining": "剩余 {{count}} 天", + "description": "您正在使用服务的试用版。请在下方选择套餐以获得完整访问权限。", + "remaining": "剩余", "title": "试用期" }, "trialUpgrade": { - "description": "升级到付费订阅以解锁所有功能并继续使用", + "description": "您的试用期即将结束。请选择合适的套餐,以继续无限制使用 VPN。", "title": "升级订阅" } }, @@ -1012,7 +1039,7 @@ "noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。", "selectSubscription": "请先选择订阅" }, - "payWithStars": "支付 {stars} Stars", + "payWithStars": "支付 Stars", "processingPayment": "处理中...", "starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。", "starsPaymentFailed": "支付失败。请重试。", @@ -1667,7 +1694,7 @@ }, "broadcasts": { "title": "广播", - "subtitle": "历史和管理", + "subtitle": "向用户群发消息", "create": "创建群发", "empty": "没有广播", "selectFilter": "选择受众", @@ -2023,7 +2050,15 @@ "offlineConv": "离线转化", "apiKey": "API 密钥", "legalFooter": "法律页脚", - "legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接" + "legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接", + "botStartVideo": "机器人开始菜单视频", + "botStartVideoDesc": "机器人会在 /start 消息中附带该视频,替代 logo 图片。文件仅上传到 Telegram 一次,我们只保存其标识符。", + "botStartVideoUpload": "上传视频", + "botStartVideoReplace": "替换视频", + "botStartVideoRemove": "删除", + "botStartVideoActive": "已设置视频", + "botStartVideoNone": "未设置视频 — 菜单使用 logo 图片", + "botStartVideoError": "视频上传失败" }, "buttons": { "color": "按钮颜色", @@ -2072,7 +2107,7 @@ "builtinButtons": "内置栏目", "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。", "buttonTextPlaceholder": "按钮文本", - "customLabelsHint": "每种语言的按钮文本", + "customLabelsHint": "留空 = 默认名称", "moveUp": "上移", "moveDown": "下移", "openIn": "打开方式", @@ -2344,7 +2379,9 @@ "periodsRequired": "请添加至少一个期限", "dailyPriceRequired": "请输入大于0的每日价格", "trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)" - } + }, + "lavaProductLabel": "Lava 产品(自动续订)", + "lavaProductDesc": "来自 Lava 后台的产品 UUID:价格与扣款周期在那里设置。留空则该套餐不支持自动续订。" }, "servers": { "title": "服务器管理", @@ -2624,10 +2661,10 @@ "programEnabled": "推荐计划已启用", "programEnabledDesc": "启用整个推荐人功能", "requisitesText": "收款信息文本", - "requisitesTextDesc": "将在合作申请表单中向用户显示", + "requisitesTextDesc": "自定义提示,告知用户提现时需填写的收款信息", "requisitesTextPlaceholder": "请输入您的收款信息...", "withdrawalEnabled": "允许提款", - "withdrawalEnabledDesc": "允许用户请求提款" + "withdrawalEnabledDesc": "允许合作伙伴申请提现推荐余额" }, "settingsLoadError": "加载推荐设置失败", "settingsSection": { @@ -3304,7 +3341,9 @@ "notificationsFailed": "(失败: {{count}})", "sendError": "发送优惠失败", "notificationsSent": "已发送通知", - "offersCreated": "已创建优惠" + "offersCreated": "已创建优惠", + "deliveryTitle": "Telegram 送达情况", + "openAsBroadcast": "作为群发查看" }, "notFound": "未找到模板", "noActiveTemplates": "没有活跃的模板", @@ -3406,7 +3445,7 @@ "usersOnlineCount": "{{count}} 在线" }, "overview": { - "bandwidth": "实时带宽", + "bandwidth": "各 inbound 流量", "connections": "连接数", "cpu": "CPU核心", "download": "下载", @@ -4939,40 +4978,48 @@ }, "activateButton": "激活", "activateCodePlaceholder": "输入礼物代码", - "activatedBy": "由 {{name}} 激活", + "activatedBy": "由 {{username}} 激活", "activateDescription": "输入您收到的礼物代码以激活订阅", "activatedGiftsTitle": "已激活的礼物", "activateError": "激活礼物失败", "activateSelfError": "您不能激活自己的礼物", "activateSuccess": "礼物已激活!", - "activateSuccessDesc": "订阅已添加到您的账户", + "activateSuccessDesc": "{{tariff}}:已为您的订阅添加 {{days}} 天", "activateTitle": "激活礼物", "activeGiftsTitle": "活跃的礼物", "codeLabel": "礼物代码", "codeReadyTitle": "您的礼物代码已就绪", "copyMessage": "复制消息", "daysShort": "天", - "deviceCount": "设备数", + "deviceCount": "{{count}} 台设备", "devicesShort": "台", "gbShort": "GB", "myGiftsEmpty": "暂无礼物", - "myGiftsEmptyDesc": "您发送或收到的礼物将显示在这里", + "myGiftsEmptyDesc": "为好友购买礼物,或激活已收到的兑换码", "pageTitle": "礼物", "receivedGiftsTitle": "已收到的礼物", "selectPeriod": "选择时长", "selectTariff": "选择套餐", - "sentTo": "已发送给 {{name}}", + "sentTo": "已发送给 {{recipient}}", "shareGift": "分享礼物", - "shareModalActivateVia": "通过以下方式激活", + "shareModalActivateVia": "通过机器人激活:", "shareModalActivateViaCabinet": "通过个人中心", - "shareText": "我为您准备了一份 Bedolaga VPN 礼物:{{code}}", + "shareText": "我给你准备了一份 Bedolaga VPN 礼物,在这里激活它:", "shareToastCopied": "链接已复制", "statusActivated": "已激活", "statusAvailable": "可激活", "tabActivate": "激活", "tabBuy": "购买", "tabMyGifts": "我的礼物", - "unlimitedTraffic": "无限流量" + "unlimitedTraffic": "无限流量", + "qrHint": "扫描以激活礼物", + "scanButton": "📷 扫描二维码", + "scanInProgress": "扫描中…", + "scanCancel": "停止扫描", + "scanDescription": "将摄像头对准礼物二维码", + "scanNotRecognized": "该二维码中没有礼物码", + "scanError": "扫描失败", + "scanNoCamera": "摄像头不可用 — 请手动输入代码" }, "news": { "title": "新闻与更新", diff --git a/src/pages/AdminBroadcastDetail.tsx b/src/pages/AdminBroadcastDetail.tsx index 1ee5c6c..1a378c0 100644 --- a/src/pages/AdminBroadcastDetail.tsx +++ b/src/pages/AdminBroadcastDetail.tsx @@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; -import { StatCard } from '@/components/stats'; import { - BanIcon, + BroadcastDeliveryStats, + BroadcastStatusBadge, +} from '../components/broadcasts/BroadcastDeliveryStats'; +import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus'; +import { DocumentIcon, EmailIcon, PhotoIcon, RefreshIcon, - SendIcon, StopIcon, TelegramIcon, - UsersIcon, VideoIcon, - XCircleIcon, } from '@/components/icons'; // Channel badge component @@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { ); } -// Status badge component -const statusConfig: Record = { - queued: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.queued', - }, - in_progress: { - bg: 'bg-accent-500/20', - text: 'text-accent-400', - labelKey: 'admin.broadcasts.status.inProgress', - }, - completed: { - bg: 'bg-success-500/20', - text: 'text-success-400', - labelKey: 'admin.broadcasts.status.completed', - }, - partial: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.partial', - }, - failed: { - bg: 'bg-error-500/20', - text: 'text-error-400', - labelKey: 'admin.broadcasts.status.failed', - }, - cancelled: { - bg: 'bg-dark-500/20', - text: 'text-dark-400', - labelKey: 'admin.broadcasts.status.cancelled', - }, - cancelling: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.cancelling', - }, -}; - -function StatusBadge({ status }: { status: string }) { - const { t } = useTranslation(); - const config = statusConfig[status] || statusConfig.queued; - return ( - - {t(config.labelKey)} - - ); -} - export default function AdminBroadcastDetail() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() { return adminBroadcastsApi.get(broadcastId); }, enabled: !!broadcastId && !isNaN(broadcastId), - refetchInterval: (query) => { - const data = query.state.data; - if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) { - return 3000; - } - return false; - }, + refetchInterval: (query) => broadcastPollInterval(query.state.data?.status), }); // Stop mutation @@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() { }, }); - const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status); + const isRunning = broadcast && isBroadcastInFlight(broadcast.status); if (!broadcastId || isNaN(broadcastId)) { navigate('/admin/broadcasts'); @@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {

{t('admin.broadcasts.detail')} #{broadcast.id}

- +

@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() { - {/* Progress */} - {isRunning && ( -

-
- {t('admin.broadcasts.progress')} - - {broadcast.progress_percent.toFixed(1)}% - -
-
-
-
-
- )} - - {/* Stats */} -
- } - tone="neutral" - /> - } - tone="success" - /> - } - tone="warning" - /> - } - tone="error" - /> -
+ {/* Progress + stats */} + {/* Target */}
diff --git a/src/pages/AdminCouponCreate.tsx b/src/pages/AdminCouponCreate.tsx index 2f6670f..7bdc31a 100644 --- a/src/pages/AdminCouponCreate.tsx +++ b/src/pages/AdminCouponCreate.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { createNumberInputHandler } from '../utils/inputHelpers'; -import { couponsApi, CouponBatchCreated } from '../api/coupons'; +import { couponsApi, type CouponBatchCreated } from '../api/coupons'; import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; import { copyToClipboard } from '../utils/clipboard'; @@ -33,6 +33,7 @@ export default function AdminCouponCreate() { const [couponsCount, setCouponsCount] = useState(50); const [priceRubles, setPriceRubles] = useState(''); const [validDays, setValidDays] = useState(''); + const [maxPerUser, setMaxPerUser] = useState(''); const [validationErrors, setValidationErrors] = useState([]); const [serverError, setServerError] = useState(null); @@ -77,6 +78,7 @@ export default function AdminCouponCreate() { coupons_count: Number(couponsCount), wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100), valid_days: validDays === '' ? 0 : Number(validDays), + max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser), }); }; @@ -275,6 +277,21 @@ export default function AdminCouponCreate() { />

{t('admin.coupons.form.validDaysHint')}

+
+ + +

{t('admin.coupons.form.maxPerUserHint')}

+
{/* Actions */} diff --git a/src/pages/AdminCouponDetail.tsx b/src/pages/AdminCouponDetail.tsx index 06ebaa4..85fd202 100644 --- a/src/pages/AdminCouponDetail.tsx +++ b/src/pages/AdminCouponDetail.tsx @@ -31,6 +31,7 @@ export default function AdminCouponDetail() { const batchId = Number(id); const [revokeConfirm, setRevokeConfirm] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); const [copied, setCopied] = useState(false); const { data: batch, isLoading } = useQuery({ @@ -70,6 +71,28 @@ export default function AdminCouponDetail() { }, }); + const deleteMutation = useMutation({ + mutationFn: () => couponsApi.deleteBatch(batchId), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] }); + setDeleteConfirm(false); + showToast({ + type: 'success', + title: t('admin.coupons.delete.success', { count: result.deleted_coupons }), + message: '', + }); + navigate('/admin/coupons', { replace: true }); + }, + onError: (err) => { + setDeleteConfirm(false); + showToast({ + type: 'error', + title: t('admin.coupons.errors.deleteFailed'), + message: getApiErrorMessage(err, ''), + }); + }, + }); + const handleCopyAll = () => { if (!links) return; void copyToClipboard(links.links.join('\n')); @@ -169,6 +192,12 @@ export default function AdminCouponDetail() { )} + {batch.max_per_user > 0 && ( +
+ {t('admin.coupons.detail.maxPerUser')} + {batch.max_per_user} +
+ )}
{t('admin.coupons.detail.validUntil')} @@ -228,6 +257,49 @@ export default function AdminCouponDetail() { )} + {/* Delete — полностью убирает партию, в отличие от отзыва */} + + + + + {/* Delete confirmation */} + {deleteConfirm && ( +
+
+

+ {t('admin.coupons.delete.confirmTitle')} +

+

+ {t('admin.coupons.delete.confirmText', { count: batch.coupons_total })} +

+ {batch.redeemed_count > 0 && ( +

+ {t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })} +

+ )} +
+ + +
+
+
+ )} + {/* Revoke confirmation */} {revokeConfirm && (
diff --git a/src/pages/AdminPromoOfferSend.tsx b/src/pages/AdminPromoOfferSend.tsx index e8e26a6..cd2aec4 100644 --- a/src/pages/AdminPromoOfferSend.tsx +++ b/src/pages/AdminPromoOfferSend.tsx @@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { promoOffersApi, - PromoOfferBroadcastRequest, + type PromoOfferBroadcastRequest, TARGET_SEGMENTS, - TargetSegment, + type TargetSegment, OFFER_TYPE_CONFIG, - OfferType, + type OfferType, } from '../api/promoOffers'; -import { adminUsersApi, UserListItem } from '../api/adminUsers'; +import { adminBroadcastsApi } from '../api/adminBroadcasts'; +import { adminUsersApi, type UserListItem } from '../api/adminUsers'; import { AdminBackButton } from '../components/admin'; +import { + BroadcastDeliveryStats, + BroadcastStatusBadge, +} from '../components/broadcasts/BroadcastDeliveryStats'; +import { broadcastPollInterval } from '../utils/broadcastStatus'; import { SendIcon, CheckIcon, @@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() { title: string; message: string; isSuccess: boolean; + broadcastId?: number | null; } | null>(null); // Query templates @@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() { queryFn: promoOffersApi.getTemplates, }); + // Recipient counts per segment — админ видит охват до отправки + const { data: segmentsData } = useQuery({ + queryKey: ['admin-promo-segments'], + queryFn: promoOffersApi.getSegments, + staleTime: 60000, + }); + + const segmentCounts = new Map( + (segmentsData?.segments || []).map((segment) => [segment.key, segment.count]), + ); + const selectedSegmentCount = segmentCounts.get(selectedTarget); + + // Delivery progress of the offer we have just sent + const broadcastId = result?.broadcastId ?? null; + const { data: delivery } = useQuery({ + queryKey: ['admin', 'broadcasts', 'detail', broadcastId], + queryFn: async () => adminBroadcastsApi.get(broadcastId as number), + enabled: broadcastId !== null, + refetchInterval: (query) => broadcastPollInterval(query.state.data?.status), + }); + const templates = templatesData?.items || []; const activeTemplates = templates.filter((t) => t.is_active); const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); @@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() { mutationFn: promoOffersApi.broadcastOffer, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers }); if (data.notifications_sent > 0 || data.notifications_failed > 0) { @@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() { title: t('admin.promoOffers.result.sentTitle'), message, isSuccess: true, + broadcastId: data.broadcast_id, }); }, onError: (error: unknown) => { @@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() { if (result) { return (
-
+

{result.title}

{result.message}

+ + {/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */} + {delivery && ( +
+
+ + {t('admin.promoOffers.result.deliveryTitle')} + + +
+ + +
+ )} +
{sendMode === 'segment' ? ( - + <> + + {selectedSegmentCount !== undefined && ( +
+ {t('admin.broadcasts.willBeSent')}:{' '} + {selectedSegmentCount} +
+ )} + ) : (
{selectedUser ? ( diff --git a/src/pages/AdminTariffCreate.tsx b/src/pages/AdminTariffCreate.tsx index f7203d2..5c1143d 100644 --- a/src/pages/AdminTariffCreate.tsx +++ b/src/pages/AdminTariffCreate.tsx @@ -50,6 +50,7 @@ export default function AdminTariffCreate() { const [selectedExternalSquad, setSelectedExternalSquad] = useState(null); const [selectedPromoGroups, setSelectedPromoGroups] = useState([]); const [dailyPriceKopeks, setDailyPriceKopeks] = useState(0); + const [lavaProductId, setLavaProductId] = useState(''); // Traffic topup const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false); @@ -122,6 +123,7 @@ export default function AdminTariffCreate() { data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [], ); setDailyPriceKopeks(data.daily_price_kopeks || 0); + setLavaProductId(data.lava_product_id || ''); setTrafficTopupEnabled(data.traffic_topup_enabled || false); setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0); setTrafficTopupPackages(data.traffic_topup_packages || {}); @@ -176,6 +178,8 @@ export default function AdminTariffCreate() { max_topup_traffic_gb: toNumber(maxTopupTrafficGb), is_daily: isDaily, daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0, + // Пустая строка отвязывает тариф от продукта Lava + lava_product_id: lavaProductId.trim(), traffic_reset_mode: trafficResetMode, }; @@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
)} + {/* Lava recurrent product */} +
+ + setLavaProductId(e.target.value)} + className="input w-full" + placeholder="6be21df9-0bcd-44ac-9c2c-3be7bc94decc" + /> +

{t('admin.tariffs.lavaProductDesc')}

+
+ {/* Traffic Limit */}
+ {/* 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/pages/RenewSubscription.tsx b/src/pages/RenewSubscription.tsx index 8b9ea7e..3bc9704 100644 --- a/src/pages/RenewSubscription.tsx +++ b/src/pages/RenewSubscription.tsx @@ -5,6 +5,7 @@ import { Navigate, useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import { useTheme } from '../hooks/useTheme'; import { getGlassColors } from '../utils/glassTheme'; +import { getMonthlyPriceKopeks } from '../utils/pricing'; import { useCurrency } from '../hooks/useCurrency'; import { useHaptic } from '../platform'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; @@ -143,8 +144,7 @@ export default function RenewSubscription() { {options.map((option) => { const isSelected = selectedPeriod === option.period_days; const canAfford = balanceKopeks >= option.price_kopeks; - const months = Math.max(1, Math.round(option.period_days / 30)); - const perMonth = option.price_kopeks / months; + const perMonth = getMonthlyPriceKopeks(option.price_kopeks, option.period_days); return (
)} + + {/* ─── Автопродление Lava ─── + Независимый от Platega движок: сиблинг того же тоггла, те же + состояния. Период задан продуктом в кабинете Lava и приезжает + числом дней, поэтому подпись строится из charge_days. */} + {!subscription.is_trial && lavaUiStateValue !== 'hidden' && ( +
+
+
+
+ {t('subscription.lavaRecurring.title')} +
+ + {lavaUiStateValue === 'off' && ( +
+ {t('subscription.lavaRecurring.autopayHint')} +
+ )} + {lavaUiStateValue === 'pending' && ( +
+ {t('subscription.lavaRecurring.statusPending')} +
+ )} + {lavaUiStateValue === 'active' && lavaInfo && ( + <> +
+ {(() => { + const periodKey = lavaPeriodLabelKey(lavaInfo.charge_days); + const amount = formatAmount((lavaInfo.amount_kopeks ?? 0) / 100); + return periodKey + ? t('subscription.lavaRecurring.amountPerPeriod', { + amount, + period: t(periodKey), + }) + : t('subscription.lavaRecurring.amountPerDays', { + amount, + days: lavaInfo.charge_days ?? 0, + }); + })()} +
+ {lavaInfo.next_charge_at && ( +
+ {t('subscription.lavaRecurring.nextCharge', { + date: new Date(lavaInfo.next_charge_at).toLocaleDateString( + uiLocale(), + { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }, + ), + })} +
+ )} + + )} + {lavaUiStateValue === 'past_due' && ( +
+ {t('subscription.lavaRecurring.statusPastDue')} +
+ )} +
+ +
+ {lavaUiStateValue === 'off' && ( + + )} + + {lavaUiStateValue === 'pending' && ( + <> + {lavaInfo?.redirect_url && ( + + )} + + + )} + + {(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && ( + + )} +
+
+
+ )}
); })() diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 1e3f8ce..3cb3b0a 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -292,6 +292,12 @@ export default function SubscriptionPurchase() { 'platega_recurrent_enabled' in purchaseOptions && purchaseOptions.platega_recurrent_enabled === true } + lavaPurchaseEnabled={ + isTariffsMode && + purchaseOptions !== undefined && + 'lava_recurrent_enabled' in purchaseOptions && + purchaseOptions.lava_recurrent_enabled === true + } onBack={() => { setShowTariffPurchase(false); setSelectedTariff(null); diff --git a/src/types/index.ts b/src/types/index.ts index 4012737..f01630c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions { // СБП-оформление (Platega recurrent): показывать кнопку «Оформить с // автооплатой СБП» рядом с покупкой с баланса platega_recurrent_enabled?: boolean; + lava_recurrent_enabled?: boolean; } export interface ClassicPurchaseOptions { @@ -661,6 +662,18 @@ export interface SbpRecurringInfo { redirect_url?: string | null; } +/** + * Автопродление Lava. В отличие от Platega период задан продуктом в кабинете + * Lava и приезжает числом дней (charge_days), а не enum-интервалом. + */ +export interface LavaRecurringInfo { + status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' + charge_days?: number; + amount_kopeks?: number; + next_charge_at?: string | null; + redirect_url?: string | null; +} + // Ticket notifications types export interface TicketNotification { id: number; diff --git a/src/utils/broadcastStatus.test.ts b/src/utils/broadcastStatus.test.ts new file mode 100644 index 0000000..52e872d --- /dev/null +++ b/src/utils/broadcastStatus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus'; + +describe('broadcastStatus', () => { + it('treats queued, in-progress and cancelling deliveries as running', () => { + expect(isBroadcastInFlight('queued')).toBe(true); + expect(isBroadcastInFlight('in_progress')).toBe(true); + expect(isBroadcastInFlight('cancelling')).toBe(true); + }); + + it('treats finished deliveries as done', () => { + expect(isBroadcastInFlight('completed')).toBe(false); + expect(isBroadcastInFlight('partial')).toBe(false); + expect(isBroadcastInFlight('failed')).toBe(false); + expect(isBroadcastInFlight('cancelled')).toBe(false); + }); + + it('handles a missing status without polling', () => { + expect(isBroadcastInFlight(undefined)).toBe(false); + expect(broadcastPollInterval(undefined)).toBe(false); + }); + + it('polls only while the delivery is running', () => { + expect(broadcastPollInterval('in_progress')).toBe(3000); + expect(broadcastPollInterval('completed')).toBe(false); + }); +}); diff --git a/src/utils/broadcastStatus.ts b/src/utils/broadcastStatus.ts new file mode 100644 index 0000000..52fb06c --- /dev/null +++ b/src/utils/broadcastStatus.ts @@ -0,0 +1,13 @@ +/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */ +export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const; + +/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */ +export function isBroadcastInFlight(status: string | undefined): boolean { + if (!status) return false; + return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status); +} + +/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */ +export function broadcastPollInterval(status: string | undefined): number | false { + return isBroadcastInFlight(status) ? 3000 : false; +} diff --git a/src/utils/lavaRecurring.test.ts b/src/utils/lavaRecurring.test.ts new file mode 100644 index 0000000..863df69 --- /dev/null +++ b/src/utils/lavaRecurring.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { isLavaFeatureDisabledError, lavaPeriodLabelKey, lavaUiState } from './lavaRecurring'; + +describe('isLavaFeatureDisabledError', () => { + it('detects exactly the disabled-feature 403', () => { + expect( + isLavaFeatureDisabledError({ + response: { status: 403, data: { detail: 'Lava recurrent disabled' } }, + }), + ).toBe(true); + }); + + it('ignores other 403s and malformed errors', () => { + // Другие guard'ы отдают тот же статус с иным detail — их прятать нельзя + expect( + isLavaFeatureDisabledError({ response: { status: 403, data: { detail: 'Blacklisted' } } }), + ).toBe(false); + expect( + isLavaFeatureDisabledError({ response: { status: 403, data: { detail: { code: 'x' } } } }), + ).toBe(false); + expect(isLavaFeatureDisabledError({ response: { status: 500, data: {} } })).toBe(false); + expect(isLavaFeatureDisabledError({ response: { status: 403 } })).toBe(false); + expect(isLavaFeatureDisabledError(null)).toBe(false); + expect(isLavaFeatureDisabledError('boom')).toBe(false); + }); +}); + +describe('lavaUiState', () => { + it('hides the block when the feature is disabled', () => { + expect(lavaUiState({ status: 'ACTIVE' }, true)).toBe('hidden'); + }); + + it('maps backend statuses to UI states', () => { + expect(lavaUiState({ status: 'PENDING' }, false)).toBe('pending'); + expect(lavaUiState({ status: 'ACTIVE' }, false)).toBe('active'); + expect(lavaUiState({ status: 'PAST_DUE' }, false)).toBe('past_due'); + expect(lavaUiState({ status: 'none' }, false)).toBe('off'); + }); + + it('degrades unknown or missing state to off instead of hanging', () => { + expect(lavaUiState({ status: 'SOMETHING_NEW' }, false)).toBe('off'); + expect(lavaUiState(undefined, false)).toBe('off'); + }); +}); + +describe('lavaPeriodLabelKey', () => { + it('labels canonical periods', () => { + expect(lavaPeriodLabelKey(30)).toBe('subscription.lavaRecurring.period.month'); + expect(lavaPeriodLabelKey(365)).toBe('subscription.lavaRecurring.period.year'); + }); + + it('returns null for non-canonical periods so the caller falls back to "N days"', () => { + expect(lavaPeriodLabelKey(45)).toBeNull(); + expect(lavaPeriodLabelKey(undefined)).toBeNull(); + }); +}); diff --git a/src/utils/lavaRecurring.ts b/src/utils/lavaRecurring.ts new file mode 100644 index 0000000..edb7dd2 --- /dev/null +++ b/src/utils/lavaRecurring.ts @@ -0,0 +1,77 @@ +import type { LavaRecurringInfo } from '../types'; + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null; +} + +/** + * true, если ошибка — это ровно "автопродление Lava выключено на бэке" + * (403 с detail==='Lava recurrent disabled'). Сравнение строгое: тот же + * 403-статус используют другие guard'ы с detail-объектом, и на любой форме + * detail (строка/объект/отсутствует) функция не должна падать. + */ +export function isLavaFeatureDisabledError(error: unknown): boolean { + if (!isRecord(error)) return false; + + const response = error.response; + if (!isRecord(response)) return false; + if (response.status !== 403) return false; + + const data = response.data; + if (!isRecord(data)) return false; + + return data.detail === 'Lava recurrent disabled'; +} + +export type LavaUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due'; + +/** + * Сводит статус автопродления Lava к состоянию UI. Бэк отдаёт из GET только + * 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED терминальны и + * не возвращаются), но любой нераспознанный статус деградирует в 'off', а не + * подвешивает интерфейс. + */ +export function lavaUiState( + info: LavaRecurringInfo | undefined, + featureDisabled: boolean, +): LavaUiState { + if (featureDisabled) return 'hidden'; + if (!info) return 'off'; + + switch (info.status) { + case 'PENDING': + return 'pending'; + case 'ACTIVE': + return 'active'; + case 'PAST_DUE': + return 'past_due'; + default: + return 'off'; + } +} + +/** + * Локализационный ключ периодичности списания. У Lava период задан продуктом + * в кабинете провайдера и приезжает числом дней, поэтому точные подписи есть + * только для канонических значений; остальное показывается как "N дней". + */ +export function lavaPeriodLabelKey(chargeDays: number | undefined): string | null { + switch (chargeDays) { + case 1: + return 'subscription.lavaRecurring.period.day'; + case 7: + return 'subscription.lavaRecurring.period.week'; + case 30: + return 'subscription.lavaRecurring.period.month'; + case 90: + return 'subscription.lavaRecurring.period.quarter'; + case 180: + return 'subscription.lavaRecurring.period.halfYear'; + case 365: + return 'subscription.lavaRecurring.period.year'; + default: + return null; + } +} diff --git a/src/utils/pricing.test.ts b/src/utils/pricing.test.ts new file mode 100644 index 0000000..feb4489 --- /dev/null +++ b/src/utils/pricing.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getMonthlyPriceKopeks } from './pricing'; + +describe('getMonthlyPriceKopeks', () => { + it('hides the monthly rate for periods of a month or shorter', () => { + expect(getMonthlyPriceKopeks(4830, 7)).toBeNull(); + expect(getMonthlyPriceKopeks(9900, 14)).toBeNull(); + expect(getMonthlyPriceKopeks(16030, 30)).toBeNull(); + }); + + it('divides by whole months for multiples of 30 days', () => { + expect(getMonthlyPriceKopeks(30000, 90)).toBe(10000); + expect(getMonthlyPriceKopeks(60000, 180)).toBe(10000); + }); + + it('prorates periods that are not whole months', () => { + expect(getMonthlyPriceKopeks(15000, 45)).toBe(10000); + expect(getMonthlyPriceKopeks(100000, 365)).toBe(8219); + }); + + it('returns null for non-finite input', () => { + expect(getMonthlyPriceKopeks(Number.NaN, 90)).toBeNull(); + expect(getMonthlyPriceKopeks(30000, Number.NaN)).toBeNull(); + expect(getMonthlyPriceKopeks(30000, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/src/utils/pricing.ts b/src/utils/pricing.ts new file mode 100644 index 0000000..5f12fd5 --- /dev/null +++ b/src/utils/pricing.ts @@ -0,0 +1,15 @@ +/** Длина «месяца» в днях — тот же множитель, что использует бэкенд при расчёте цены за месяц. */ +const DAYS_IN_MONTH = 30; + +/** + * Цена за месяц (в копейках) для периода длиной `days` дней. + * + * Возвращает `null`, когда месячная ставка не имеет смысла: для периода + * в месяц и короче она либо повторяет цену периода (30 дней), либо + * выдаёт цену периода за месячную (7 дней → цена семи дней «за месяц»). + */ +export function getMonthlyPriceKopeks(priceKopeks: number, days: number): number | null { + if (!Number.isFinite(priceKopeks) || !Number.isFinite(days)) return null; + if (days <= DAYS_IN_MONTH) return null; + return Math.round((priceKopeks * DAYS_IN_MONTH) / days); +} 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..8152982 --- /dev/null +++ b/src/utils/qrScanner.ts @@ -0,0 +1,106 @@ +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'; +// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js). +// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами +// страницы. При несовпадении браузер откажется исполнять скрипт — сканер +// деградирует в «камера недоступна», а не запускает чужой код. +const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+'; + +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; + script.integrity = HTML5_QRCODE_SRI; + script.crossOrigin = 'anonymous'; + script.referrerPolicy = 'no-referrer'; + document.head.appendChild(script); + await new Promise((resolve) => { + script.onload = () => resolve(); + // onerror срабатывает и при провале проверки целостности — вызывающий + // получит undefined и покажет «камера недоступна». + 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; +}