From 799e986d841aadf8a02ccc8287320a025e6a0aac Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:52:13 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(coupons):=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=20=D0=BE=D0=BF=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BA=D1=83=D0=BF=D0=BE=D0=BD=D0=BE=D0=B2=20+=20=D0=BF=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI для купонов из BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#3042 (API: PR #3044 бота): - /admin/coupons — список партий с погашениями и offset-пагинацией; - /admin/coupons/create — мастер партии (тариф, дни, количество, учётная оптовая цена, срок), после создания — экран со ссылками (копирование всех, скачивание .txt); - /admin/coupons/:id — карточка со статистикой, экспорт актуальных ссылок, отзыв непогашенных за PermissionGate coupons:edit с подтверждением и тостом; - /coupon/:token — публичная страница купона: тариф/срок, кнопка активации в боте, для залогиненных — активация прямо в кабинете с маппингом структурированных кодов ошибок {code, message}; - пункт меню в разделе тарифов (гейт coupons:read), лениво загружаемые роуты, локали ru/en (zh/fa падают на ru-фолбэк). --- src/App.tsx | 42 +++++ src/api/coupons.ts | 112 ++++++++++++ src/locales/en.json | 107 ++++++++++++ src/locales/ru.json | 107 ++++++++++++ src/pages/AdminCouponCreate.tsx | 298 ++++++++++++++++++++++++++++++++ src/pages/AdminCouponDetail.tsx | 271 +++++++++++++++++++++++++++++ src/pages/AdminCoupons.tsx | 199 +++++++++++++++++++++ src/pages/AdminPanel.tsx | 6 + src/pages/CouponStatus.tsx | 182 +++++++++++++++++++ 9 files changed, 1324 insertions(+) create mode 100644 src/api/coupons.ts create mode 100644 src/pages/AdminCouponCreate.tsx create mode 100644 src/pages/AdminCouponDetail.tsx create mode 100644 src/pages/AdminCoupons.tsx create mode 100644 src/pages/CouponStatus.tsx diff --git a/src/App.tsx b/src/App.tsx index 59da62b..dfb426e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,6 +94,10 @@ const AdminBroadcasts = lazyWithRetry(() => import('./pages/AdminBroadcasts')); const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate')); const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes')); const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate')); +const AdminCoupons = lazyWithRetry(() => import('./pages/AdminCoupons')); +const AdminCouponCreate = lazyWithRetry(() => import('./pages/AdminCouponCreate')); +const AdminCouponDetail = lazyWithRetry(() => import('./pages/AdminCouponDetail')); +const CouponStatus = lazyWithRetry(() => import('./pages/CouponStatus')); const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats')); const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups')); const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate')); @@ -294,6 +298,14 @@ function App() { } /> + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/admin/coupons', { params }); + return response.data; + }, + + getBatch: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/coupons/${id}`); + return response.data; + }, + + createBatch: async (data: CouponBatchCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/coupons', data); + return response.data; + }, + + getBatchLinks: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/coupons/${id}/links`); + return response.data; + }, + + revokeBatch: async (id: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/coupons/${id}/revoke`); + return response.data; + }, + + // User: redeem a coupon for the current cabinet user + redeemCoupon: async (token: string): Promise => { + const response = await apiClient.post('/cabinet/coupon/redeem', { token }); + return response.data; + }, + + // Public: coupon info by token (no auth) + getCouponStatus: async (token: string): Promise => { + const response = await apiClient.get(`/cabinet/coupon/${token}/status`); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 801f63c..ae775af 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1241,6 +1241,7 @@ "campaigns": "Campaigns", "promoOffers": "Promo Offers", "promocodes": "Promo Codes", + "coupons": "Coupons", "promoGroups": "Discount Groups", "trafficUsage": "Traffic Usage", "updates": "Updates", @@ -3031,6 +3032,84 @@ "description": "This will remove partner privileges. The partner will no longer earn commissions from referrals." } }, + "coupons": { + "title": "Coupons", + "subtitle": "Wholesale batches of one-time subscription links", + "addBatch": "Create batch", + "noBatches": "No batches yet", + "days": "d.", + "stats": { + "batches": "Batches", + "active": "Active", + "redeemed": "Redeemed", + "revoked": "Revoked" + }, + "list": { + "revoked": "Revoked", + "until": "Until", + "price": "Wholesale" + }, + "pagination": { + "prev": "Previous", + "next": "Next", + "page": "Page {{current}} of {{total}}" + }, + "form": { + "title": "New coupon batch", + "subtitle": "Every coupon is a one-time subscription link", + "name": "Batch name", + "namePlaceholder": "E.g. the partner name", + "tariff": "Tariff", + "selectTariff": "Select a tariff", + "periodDays": "Subscription days per coupon", + "couponsCount": "Number of coupons (1–500)", + "price": "Wholesale price per coupon, ₽", + "priceHint": "Bookkeeping only, 0 — not set", + "validDays": "Coupon lifetime, days", + "validDaysHint": "0 or empty — perpetual", + "create": "Create batch", + "creating": "Creating…", + "cancel": "Cancel" + }, + "validation": { + "nameRequired": "Enter the batch name", + "tariffRequired": "Select a tariff", + "periodInvalid": "Subscription days: 1 to 3650", + "countInvalid": "Number of coupons: 1 to 500" + }, + "created": { + "title": "Batch created", + "linksLabel": "Links ({{count}})", + "copyAll": "Copy all", + "copied": "Copied", + "download": "Download .txt", + "toList": "Back to batches", + "toBatch": "Open batch" + }, + "detail": { + "title": "Batch #{{id}}", + "total": "Total coupons", + "price": "Wholesale price", + "priceTotal": "total", + "validUntil": "Valid until", + "perpetual": "Perpetual", + "createdAt": "Created", + "linksTitle": "Active links ({{count}})" + }, + "revoke": { + "button": "Revoke unredeemed ({{count}})", + "confirmTitle": "Revoke the batch?", + "confirmText": "{{count}} unredeemed coupons will be revoked. Their links will stop working. This cannot be undone.", + "confirm": "Revoke", + "cancel": "Cancel", + "success": "Coupons revoked: {{count}}" + }, + "errors": { + "loadFailed": "Batch not found", + "createFailed": "Failed to create the batch", + "revokeFailed": "Failed to revoke the batch" + } + }, "promocodes": { "title": "Promo Codes", "subtitle": "Manage promo codes and discount groups", @@ -5086,6 +5165,34 @@ "nMonths": "{{count}} months" } }, + "coupon": { + "title": "Subscription coupon", + "subtitle": "A one-time coupon — redeem it to get or extend a subscription", + "tariff": "Tariff", + "period": "Period", + "daysSuffix": "d.", + "validUntil": "Redeem before", + "openBot": "Redeem in the bot", + "redeemCabinet": "Redeem in the cabinet", + "redeeming": "Redeeming…", + "needAuth": "Sign in to redeem the coupon in the cabinet — or open the link in the bot", + "notFound": { + "title": "Coupon not found", + "text": "The coupon does not exist, was already used or has expired" + }, + "success": { + "activated": "Subscription activated!", + "renewed": "Subscription extended!", + "until": "Valid until" + }, + "errors": { + "invalid": "Coupon not found or already used", + "expired": "The coupon has expired", + "already_redeemed_by_you": "You have already redeemed this coupon", + "internal": "Server error, try again later", + "generic": "Failed to redeem the coupon" + } + }, "gift": { "title": "Gift Subscription", "subtitle": "Send a VPN subscription as a gift", diff --git a/src/locales/ru.json b/src/locales/ru.json index 92b5149..134b156 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1263,6 +1263,7 @@ "campaigns": "Кампании", "promoOffers": "Промопредложения", "promocodes": "Промокоды", + "coupons": "Купоны", "promoGroups": "Группы скидок", "trafficUsage": "Расход трафика", "updates": "Обновления", @@ -3433,6 +3434,84 @@ "description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов." } }, + "coupons": { + "title": "Купоны", + "subtitle": "Оптовые партии одноразовых ссылок на подписку", + "addBatch": "Создать партию", + "noBatches": "Партий пока нет", + "days": "дн.", + "stats": { + "batches": "Партий", + "active": "Активных", + "redeemed": "Погашено", + "revoked": "Отозвано" + }, + "list": { + "revoked": "Отозвана", + "until": "До", + "price": "Опт" + }, + "pagination": { + "prev": "Назад", + "next": "Вперёд", + "page": "Стр. {{current}} из {{total}}" + }, + "form": { + "title": "Новая партия купонов", + "subtitle": "Каждый купон — одноразовая ссылка на подписку", + "name": "Название партии", + "namePlaceholder": "Например, имя партнёра", + "tariff": "Тариф", + "selectTariff": "Выберите тариф", + "periodDays": "Дней подписки на купон", + "couponsCount": "Количество купонов (1–500)", + "price": "Оптовая цена за купон, ₽", + "priceHint": "Только для учёта, 0 — не указывать", + "validDays": "Срок действия купонов, дней", + "validDaysHint": "0 или пусто — бессрочно", + "create": "Создать партию", + "creating": "Создание…", + "cancel": "Отмена" + }, + "validation": { + "nameRequired": "Укажите название партии", + "tariffRequired": "Выберите тариф", + "periodInvalid": "Дней подписки: от 1 до 3650", + "countInvalid": "Количество купонов: от 1 до 500" + }, + "created": { + "title": "Партия создана", + "linksLabel": "Ссылки ({{count}} шт.)", + "copyAll": "Скопировать все", + "copied": "Скопировано", + "download": "Скачать .txt", + "toList": "К списку партий", + "toBatch": "К партии" + }, + "detail": { + "title": "Партия #{{id}}", + "total": "Всего купонов", + "price": "Оптовая цена", + "priceTotal": "итого", + "validUntil": "Действует до", + "perpetual": "Бессрочно", + "createdAt": "Создана", + "linksTitle": "Активные ссылки ({{count}} шт.)" + }, + "revoke": { + "button": "Отозвать непогашенные ({{count}})", + "confirmTitle": "Отозвать партию?", + "confirmText": "Будет отозвано {{count}} непогашенных купонов. Их ссылки перестанут работать. Действие необратимо.", + "confirm": "Отозвать", + "cancel": "Отмена", + "success": "Отозвано купонов: {{count}}" + }, + "errors": { + "loadFailed": "Партия не найдена", + "createFailed": "Не удалось создать партию", + "revokeFailed": "Не удалось отозвать партию" + } + }, "promocodes": { "title": "Промокоды", "subtitle": "Управление промокодами и группами скидок", @@ -5643,6 +5722,34 @@ "nMonths": "{{count}} мес" } }, + "coupon": { + "title": "Купон на подписку", + "subtitle": "Одноразовый купон — активируйте, чтобы получить или продлить подписку", + "tariff": "Тариф", + "period": "Срок", + "daysSuffix": "дн.", + "validUntil": "Активировать до", + "openBot": "Активировать в боте", + "redeemCabinet": "Активировать в кабинете", + "redeeming": "Активация…", + "needAuth": "Чтобы активировать купон в кабинете, войдите в аккаунт — или откройте ссылку в боте", + "notFound": { + "title": "Купон не найден", + "text": "Купон не существует, уже использован или истёк" + }, + "success": { + "activated": "Подписка активирована!", + "renewed": "Подписка продлена!", + "until": "Действует до" + }, + "errors": { + "invalid": "Купон не найден или уже использован", + "expired": "Срок действия купона истёк", + "already_redeemed_by_you": "Вы уже активировали этот купон", + "internal": "Ошибка сервера, попробуйте позже", + "generic": "Не удалось активировать купон" + } + }, "gift": { "title": "Подарить подписку", "subtitle": "Отправьте VPN-подписку в подарок", diff --git a/src/pages/AdminCouponCreate.tsx b/src/pages/AdminCouponCreate.tsx new file mode 100644 index 0000000..2f6670f --- /dev/null +++ b/src/pages/AdminCouponCreate.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +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 { tariffsApi } from '../api/tariffs'; +import { usePlatform } from '../platform/hooks/usePlatform'; +import { copyToClipboard } from '../utils/clipboard'; +import { getApiErrorMessage } from '../utils/api-error'; +import { BackIcon, CheckIcon, CopyIcon, DownloadIcon } from '@/components/icons'; + +const downloadLinksFile = (batch: CouponBatchCreated | { id: number; links: string[] }) => { + const blob = new Blob([batch.links.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `coupons_batch_${batch.id}.txt`; + anchor.click(); + URL.revokeObjectURL(url); +}; + +export default function AdminCouponCreate() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { capabilities } = usePlatform(); + + // Form state + const [name, setName] = useState(''); + const [tariffId, setTariffId] = useState(null); + const [periodDays, setPeriodDays] = useState(30); + const [couponsCount, setCouponsCount] = useState(50); + const [priceRubles, setPriceRubles] = useState(''); + const [validDays, setValidDays] = useState(''); + const [validationErrors, setValidationErrors] = useState([]); + const [serverError, setServerError] = useState(null); + + // Result state (batch created — show the generated links) + const [created, setCreated] = useState(null); + const [copied, setCopied] = useState(false); + + const { data: tariffsData } = useQuery({ + queryKey: ['admin-tariffs-for-coupon'], + queryFn: () => tariffsApi.getTariffs(true), + }); + const tariffs = (tariffsData?.tariffs || []).filter((tariff) => tariff.is_active); + + const createMutation = useMutation({ + mutationFn: couponsApi.createBatch, + onSuccess: (batch) => { + queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] }); + setCreated(batch); + }, + onError: (err) => { + setServerError(getApiErrorMessage(err, t('admin.coupons.errors.createFailed'))); + }, + }); + + const validate = (): boolean => { + const errors: string[] = []; + if (!name.trim()) errors.push('nameRequired'); + if (!tariffId) errors.push('tariffRequired'); + if (!periodDays || periodDays < 1 || periodDays > 3650) errors.push('periodInvalid'); + if (!couponsCount || couponsCount < 1 || couponsCount > 500) errors.push('countInvalid'); + setValidationErrors(errors); + return errors.length === 0; + }; + + const handleSubmit = () => { + setServerError(null); + if (!validate()) return; + createMutation.mutate({ + name: name.trim(), + tariff_id: tariffId!, + period_days: Number(periodDays), + coupons_count: Number(couponsCount), + wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100), + valid_days: validDays === '' ? 0 : Number(validDays), + }); + }; + + const handleCopyAll = () => { + if (!created) return; + void copyToClipboard(created.links.join('\n')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Step 2: the batch is created — show the one-time links + if (created) { + return ( +
+
+

{t('admin.coupons.created.title')}

+

+ #{created.id} {created.name} · {created.tariff_name} · {created.period_days}{' '} + {t('admin.coupons.days')} +

+
+ +
+
+ + {t('admin.coupons.created.linksLabel', { count: created.links.length })} + +
+ + +
+
+