feat(coupons): раздел оптовых купонов + публичная страница активации

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-фолбэк).
This commit is contained in:
kewldan
2026-07-11 23:52:13 +03:00
parent 20565b8f59
commit 799e986d84
9 changed files with 1324 additions and 0 deletions

View File

@@ -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 (1500)",
"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",

View File

@@ -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": "Количество купонов (1500)",
"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-подписку в подарок",