Merge branch 'dev' into build/docker-skip-tsc

This commit is contained in:
kewldan
2026-07-12 01:46:33 +03:00
committed by GitHub
29 changed files with 1689 additions and 1691 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run ESLint
- name: Run linter
run: npm run lint
- name: Check formatting

View File

@@ -1,6 +0,0 @@
dist
node_modules
public
*.min.js
*.min.css
package-lock.json

View File

@@ -1,13 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "auto",
"jsxSingleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -0,0 +1,35 @@
language js
// Cross-platform guard (was eslint no-restricted-properties): these browser
// APIs misbehave inside the Telegram WebView. Route them through the platform
// abstraction instead. The platform adapters and the clipboard util (the
// canonical implementations) are exempted by the filename conditions below.
file(name=$name, body=$body) where {
// Exempt the canonical implementations only, matching the old eslint override
// scope (`src/platform/**`, `src/utils/clipboard.ts`) — an anchored path
// fragment, not a bare "platform" substring that would also spare any
// unrelated file with "platform" in its name.
$name <: not includes "src/platform/",
$name <: not includes or {
"utils/clipboard.ts",
"utils/clipboard.js"
},
$body <: contains bubble or {
`window.confirm` as $use where {
register_diagnostic(span=$use, message="Use useNativeDialog().confirm — window.confirm is silently ignored in the Telegram WebView.", severity="error")
},
`window.alert` as $use where {
register_diagnostic(span=$use, message="Use useNativeDialog().alert — window.alert is silently ignored in the Telegram WebView.", severity="error")
},
`window.open` as $use where {
register_diagnostic(span=$use, message="Use usePlatform().openLink / openTelegramLink — window.open is intercepted by the Telegram WebView.", severity="error")
},
`window.prompt` as $use where {
register_diagnostic(span=$use, message="Use usePrompt() (PromptDialogHost) — window.prompt is not supported in the Telegram WebView.", severity="error")
},
`navigator.clipboard` as $use where {
register_diagnostic(span=$use, message="Use copyToClipboard from @/utils/clipboard — it falls back to execCommand when the Clipboard API is unavailable (Telegram WebView).", severity="error")
}
}
}

94
biome.json Normal file
View File

@@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"files": {
"includes": [
"**",
"!dist",
"!node_modules",
"!public",
"!package-lock.json",
"!**/*.min.js",
"!**/*.min.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "auto"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all",
"arrowParentheses": "always",
"bracketSpacing": true
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"recommended": false
},
"correctness": {
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn",
"noInnerDeclarations": "warn",
"noUnsafeOptionalChaining": "warn"
},
"suspicious": {
"noExplicitAny": "warn",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "warn",
"useIterableCallbackReturn": "warn"
},
"security": {
"noGlobalEval": "error",
"noDangerouslySetInnerHtml": "warn"
}
}
},
"assist": {
"enabled": false
},
"plugins": ["./biome-plugins/telegram-webview-guards.grit"],
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["**/*.css"],
"linter": {
"rules": {
"suspicious": {
"noDuplicateProperties": "off",
"noUnknownAtRules": {
"options": {
"ignore": ["tailwind"]
},
"level": "error"
}
},
"correctness": {
"noUnknownFunction": {
"options": {
"ignore": ["theme", "screen"]
},
"level": "error"
}
},
"complexity": {
"noImportantStyles": "off"
}
}
}
}
]
}

View File

@@ -1,88 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['dist', 'node_modules', 'public'],
},
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
'react-hooks/purity': 'off',
'react-hooks/variables': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
],
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-script-url': 'error',
// Cross-platform guard: these browser APIs misbehave inside the Telegram WebView.
// Route them through the platform abstraction instead. The platform adapters and
// the clipboard util (the canonical implementations) are exempted below.
'no-restricted-properties': [
'error',
{
object: 'window',
property: 'confirm',
message:
'Use useNativeDialog().confirm — window.confirm is silently ignored in the Telegram WebView.',
},
{
object: 'window',
property: 'alert',
message:
'Use useNativeDialog().alert — window.alert is silently ignored in the Telegram WebView.',
},
{
object: 'window',
property: 'open',
message:
'Use usePlatform().openLink / openTelegramLink — window.open is intercepted by the Telegram WebView.',
},
{
object: 'window',
property: 'prompt',
message:
'Use usePrompt() (PromptDialogHost) — window.prompt is not supported in the Telegram WebView.',
},
{
object: 'navigator',
property: 'clipboard',
message:
'Use copyToClipboard from @/utils/clipboard — it falls back to execCommand when the Clipboard API is unavailable (Telegram WebView).',
},
],
},
},
{
// Canonical implementations that intentionally call the restricted browser APIs.
files: ['src/platform/**/*.{ts,tsx}', 'src/utils/clipboard.ts'],
rules: {
'no-restricted-properties': 'off',
},
},
eslintConfigPrettier,
);

1644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,11 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:docker": "vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"format": "biome format --write .",
"format:check": "biome format .",
"check": "biome check .",
"type-check": "tsc --noEmit",
"preview": "vite preview",
"prepare": "husky"
@@ -75,35 +75,26 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@biomejs/biome": "2.5.3",
"@types/dompurify": "^3.0.5",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/react-twemoji": "^0.4.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.2.2",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
"*.{ts,tsx,json}": [
"biome check --write --no-errors-on-unmatched"
],
"*.{css,json}": [
"prettier --write"
"*.css": [
"biome format --write --no-errors-on-unmatched"
]
}
}

View File

@@ -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() {
</LazyPage>
}
/>
<Route
path="/coupon/:token"
element={
<LazyPage>
<CouponStatus />
</LazyPage>
}
/>
<Route
path="/buy/:slug"
element={
@@ -839,6 +851,36 @@ function App() {
</PermissionRoute>
}
/>
<Route
path="/admin/coupons"
element={
<PermissionRoute permission="coupons:read">
<LazyPage>
<AdminCoupons />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/coupons/create"
element={
<PermissionRoute permission="coupons:create">
<LazyPage>
<AdminCouponCreate />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/coupons/:id"
element={
<PermissionRoute permission="coupons:read">
<LazyPage>
<AdminCouponDetail />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/promocodes/:id/stats"
element={

View File

@@ -284,11 +284,7 @@ export const banSystemApi = {
// Users
getUsers: async (
params: {
offset?: number;
limit?: number;
status?: string;
} = {},
params: { offset?: number; limit?: number; status?: string } = {},
): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
return response.data;
@@ -349,11 +345,7 @@ export const banSystemApi = {
// Agents
getAgents: async (
params: {
search?: string;
health?: string;
status?: string;
} = {},
params: { search?: string; health?: string; status?: string } = {},
): Promise<BanAgentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
return response.data;

112
src/api/coupons.ts Normal file
View File

@@ -0,0 +1,112 @@
import apiClient from './client';
// ============== Types ==============
export interface CouponBatch {
id: number;
name: string;
tariff_id: number | null;
tariff_name: string | null;
period_days: number;
coupons_total: number;
wholesale_price_kopeks: number;
valid_until: string | null;
is_revoked: boolean;
created_at: string;
active_count: number;
redeemed_count: number;
revoked_count: number;
}
export interface CouponBatchListResponse {
items: CouponBatch[];
total: number;
limit: number;
offset: number;
}
export interface CouponBatchCreateRequest {
name: string;
tariff_id: number;
period_days: number;
coupons_count: number;
wholesale_price_kopeks?: number;
valid_days?: number;
}
export interface CouponBatchCreated extends CouponBatch {
links: string[];
tokens: string[];
}
export interface CouponBatchLinks {
batch_id: number;
count: number;
links: string[];
tokens: string[];
}
export interface CouponBatchRevokeResponse {
revoked_count: number;
batch: CouponBatch;
}
export interface CouponRedeemResponse {
success: boolean;
tariff_name: string;
period_days: number;
renewed: boolean;
end_date: string | null;
}
export interface CouponStatus {
tariff_name: string;
period_days: number;
valid_until: string | null;
bot_link: string | null;
}
// ============== API ==============
export const couponsApi = {
// Admin: batches
getBatches: async (params?: {
limit?: number;
offset?: number;
}): Promise<CouponBatchListResponse> => {
const response = await apiClient.get('/cabinet/admin/coupons', { params });
return response.data;
},
getBatch: async (id: number): Promise<CouponBatch> => {
const response = await apiClient.get(`/cabinet/admin/coupons/${id}`);
return response.data;
},
createBatch: async (data: CouponBatchCreateRequest): Promise<CouponBatchCreated> => {
const response = await apiClient.post('/cabinet/admin/coupons', data);
return response.data;
},
getBatchLinks: async (id: number): Promise<CouponBatchLinks> => {
const response = await apiClient.get(`/cabinet/admin/coupons/${id}/links`);
return response.data;
},
revokeBatch: async (id: number): Promise<CouponBatchRevokeResponse> => {
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<CouponRedeemResponse> => {
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
return response.data;
},
// Public: coupon info by token (no auth)
getCouponStatus: async (token: string): Promise<CouponStatus> => {
const response = await apiClient.get(`/cabinet/coupon/${token}/status`);
return response.data;
},
};

View File

@@ -52,7 +52,8 @@ const cardVariants = cva(
);
export interface CardProps
extends Omit<HTMLMotionProps<'div'>, 'children'>, VariantProps<typeof cardVariants> {
extends Omit<HTMLMotionProps<'div'>, 'children'>,
VariantProps<typeof cardVariants> {
children: ReactNode;
asChild?: boolean;
haptic?: boolean;

View File

@@ -7,7 +7,8 @@ import { buttonTap, buttonHover, springTransition } from '../../motion/transitio
import { buttonVariants, type ButtonVariants } from './Button.variants';
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>, ButtonVariants {
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>,
ButtonVariants {
children: ReactNode;
asChild?: boolean;
disabled?: boolean;

View File

@@ -60,9 +60,8 @@ export const DialogOverlay = forwardRef<HTMLDivElement, DialogOverlayProps>(
DialogOverlay.displayName = 'DialogOverlay';
// Content
export interface DialogContentProps extends ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
export interface DialogContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}

View File

@@ -16,9 +16,8 @@ export {
} from '@radix-ui/react-dropdown-menu';
// SubTrigger
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger
> {
export interface DropdownMenuSubTriggerProps
extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> {
inset?: boolean;
}
@@ -115,9 +114,8 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
DropdownMenuContent.displayName = 'DropdownMenuContent';
// Item
export interface DropdownMenuItemProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Item
> {
export interface DropdownMenuItemProps
extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> {
inset?: boolean;
destructive?: boolean;
}
@@ -210,9 +208,8 @@ export const DropdownMenuRadioItem = forwardRef<HTMLDivElement, DropdownMenuRadi
DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';
// Label
export interface DropdownMenuLabelProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> {
export interface DropdownMenuLabelProps
extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {
inset?: boolean;
}

View File

@@ -13,9 +13,8 @@ export {
} from '@radix-ui/react-popover';
// Content
export interface PopoverContentProps extends ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content
> {
export interface PopoverContentProps
extends ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
showCloseButton?: boolean;
}

View File

@@ -9,9 +9,8 @@ import { dropdown, dropdownTransition } from '../../motion/transitions';
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
> {
export interface SelectTriggerProps
extends ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> {
placeholder?: string;
}

View File

@@ -1,5 +1,5 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion, AnimatePresence, useDragControls, PanInfo } from 'framer-motion';
import { motion, AnimatePresence, useDragControls, type PanInfo } from 'framer-motion';
import {
forwardRef,
type ComponentPropsWithoutRef,
@@ -97,9 +97,8 @@ export const SheetOverlay = forwardRef<HTMLDivElement, SheetOverlayProps>(
SheetOverlay.displayName = 'SheetOverlay';
// Content
export interface SheetContentProps extends ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
export interface SheetContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showDragHandle?: boolean;
showCloseButton?: boolean;
enableDragToClose?: boolean;

View File

@@ -3,10 +3,8 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
export interface SwitchProps extends Omit<
ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
'onChange'
> {
export interface SwitchProps
extends Omit<ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>, 'onChange'> {
label?: string;
description?: string;
onChange?: (checked: boolean) => void;

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-подписку в подарок",

View File

@@ -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<number | null>(null);
const [periodDays, setPeriodDays] = useState<number | ''>(30);
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null);
// Result state (batch created — show the generated links)
const [created, setCreated] = useState<CouponBatchCreated | null>(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 (
<div className="mx-auto max-w-2xl animate-fade-in">
<div className="mb-6">
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.created.title')}</h1>
<p className="text-sm text-dark-400">
#{created.id} {created.name} · {created.tariff_name} · {created.period_days}{' '}
{t('admin.coupons.days')}
</p>
</div>
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.coupons.created.linksLabel', { count: created.links.length })}
</span>
<div className="flex gap-2">
<button
onClick={handleCopyAll}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('admin.coupons.created.copied') : t('admin.coupons.created.copyAll')}
</button>
<button
onClick={() => downloadLinksFile(created)}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<DownloadIcon />
{t('admin.coupons.created.download')}
</button>
</div>
</div>
<textarea
readOnly
value={created.links.join('\n')}
rows={Math.min(12, created.links.length)}
className="input w-full resize-none font-mono text-xs"
/>
</div>
<div className="flex gap-3">
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
{t('admin.coupons.created.toList')}
</button>
<button
onClick={() => navigate(`/admin/coupons/${created.id}`)}
className="btn-primary flex-1"
>
{t('admin.coupons.created.toBatch')}
</button>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin/coupons')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.form.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.coupons.form.subtitle')}</p>
</div>
</div>
{/* Validation errors */}
{validationErrors.length > 0 && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4">
<ul className="list-inside list-disc space-y-1 text-sm text-error-400">
{validationErrors.map((error) => (
<li key={error}>{t(`admin.coupons.validation.${error}`)}</li>
))}
</ul>
</div>
)}
{serverError && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{serverError}
</div>
)}
<div className="space-y-4 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
{/* Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
placeholder={t('admin.coupons.form.namePlaceholder')}
className="input w-full"
/>
</div>
{/* Tariff */}
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.tariff')}
</label>
<select
value={tariffId ?? ''}
onChange={(e) => setTariffId(e.target.value ? Number(e.target.value) : null)}
className="input w-full"
>
<option value="">{t('admin.coupons.form.selectTariff')}</option>
{tariffs.map((tariff) => (
<option key={tariff.id} value={tariff.id}>
{tariff.name}
</option>
))}
</select>
</div>
{/* Period + count */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.periodDays')}
</label>
<input
type="number"
value={periodDays}
onChange={createNumberInputHandler(setPeriodDays, 1, 3650)}
min={1}
max={3650}
className="input w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.couponsCount')}
</label>
<input
type="number"
value={couponsCount}
onChange={createNumberInputHandler(setCouponsCount, 1, 500)}
min={1}
max={500}
className="input w-full"
/>
</div>
</div>
{/* Wholesale price + validity */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.price')}
</label>
<input
type="number"
value={priceRubles}
onChange={createNumberInputHandler(setPriceRubles, 0)}
min={0}
step="0.01"
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.priceHint')}</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.validDays')}
</label>
<input
type="number"
value={validDays}
onChange={createNumberInputHandler(setValidDays, 0, 3650)}
min={0}
max={3650}
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 border-t border-dark-700 pt-4">
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
{t('admin.coupons.form.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={createMutation.isPending}
className={`btn-primary flex-1 ${createMutation.isPending ? 'cursor-not-allowed opacity-50' : ''}`}
>
{createMutation.isPending
? t('admin.coupons.form.creating')
: t('admin.coupons.form.create')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { couponsApi } from '../api/coupons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
import { formatPrice, formatShortDate } from '../utils/format';
import { getApiErrorMessage } from '../utils/api-error';
import { useToast } from '../components/Toast';
import { PermissionGate } from '../components/auth/PermissionGate';
import {
BackIcon,
CheckIcon,
CheckCircleIcon,
ChartBarIcon,
CopyIcon,
DownloadIcon,
TicketIcon,
} from '@/components/icons';
import { StatCard } from '../components/stats';
export default function AdminCouponDetail() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const { showToast } = useToast();
const batchId = Number(id);
const [revokeConfirm, setRevokeConfirm] = useState(false);
const [copied, setCopied] = useState(false);
const { data: batch, isLoading } = useQuery({
queryKey: ['admin-coupon-batch', batchId],
queryFn: () => couponsApi.getBatch(batchId),
enabled: Number.isFinite(batchId),
});
const hasActive = (batch?.active_count ?? 0) > 0;
const { data: links } = useQuery({
queryKey: ['admin-coupon-batch-links', batchId],
queryFn: () => couponsApi.getBatchLinks(batchId),
enabled: Number.isFinite(batchId) && hasActive,
});
const revokeMutation = useMutation({
mutationFn: () => couponsApi.revokeBatch(batchId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batch', batchId] });
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batch-links', batchId] });
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
setRevokeConfirm(false);
showToast({
type: 'success',
title: t('admin.coupons.revoke.success', { count: result.revoked_count }),
message: result.batch.name,
});
},
onError: (err) => {
setRevokeConfirm(false);
showToast({
type: 'error',
title: t('admin.coupons.errors.revokeFailed'),
message: getApiErrorMessage(err, ''),
});
},
});
const handleCopyAll = () => {
if (!links) return;
void copyToClipboard(links.links.join('\n'));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
if (!links) return;
const blob = new Blob([links.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_${batchId}.txt`;
anchor.click();
URL.revokeObjectURL(url);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
if (!batch) {
return (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.coupons.errors.loadFailed')}</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin/coupons')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div className="min-w-0">
<h1 className="truncate text-xl font-bold text-dark-100">
{t('admin.coupons.detail.title', { id: batch.id })} · {batch.name}
</h1>
<p className="text-sm text-dark-400">
{batch.tariff_name || '—'} · {batch.period_days} {t('admin.coupons.days')}
{batch.is_revoked && (
<span className="ml-2 rounded bg-error-500/20 px-2 py-0.5 text-xs text-error-400">
{t('admin.coupons.list.revoked')}
</span>
)}
</p>
</div>
</div>
{/* Stats */}
<div className="mb-6 grid grid-cols-3 gap-3">
<StatCard
label={t('admin.coupons.stats.active')}
value={batch.active_count}
icon={<TicketIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.coupons.stats.redeemed')}
value={batch.redeemed_count}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.coupons.stats.revoked')}
value={batch.revoked_count}
icon={<ChartBarIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
{/* Info card */}
<div className="mb-6 space-y-2 rounded-xl border border-dark-700 bg-dark-800 p-4 text-sm">
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.total')}</span>
<span className="text-dark-100">{batch.coupons_total}</span>
</div>
{batch.wholesale_price_kopeks > 0 && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.price')}</span>
<span className="text-dark-100">
{formatPrice(batch.wholesale_price_kopeks, i18n.language)} ·{' '}
{t('admin.coupons.detail.priceTotal')}{' '}
{formatPrice(batch.wholesale_price_kopeks * batch.coupons_total, i18n.language)}
</span>
</div>
)}
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100">
{batch.valid_until
? formatShortDate(batch.valid_until)
: t('admin.coupons.detail.perpetual')}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.createdAt')}</span>
<span className="text-dark-100">{formatShortDate(batch.created_at)}</span>
</div>
</div>
{/* Links */}
{hasActive && links && (
<div className="mb-6 rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium text-dark-200">
{t('admin.coupons.detail.linksTitle', { count: links.count })}
</span>
<div className="flex gap-2">
<button
onClick={handleCopyAll}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('admin.coupons.created.copied') : t('admin.coupons.created.copyAll')}
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<DownloadIcon />
{t('admin.coupons.created.download')}
</button>
</div>
</div>
<textarea
readOnly
value={links.links.join('\n')}
rows={Math.min(10, links.count)}
className="input w-full resize-none font-mono text-xs"
/>
</div>
)}
{/* Revoke */}
{hasActive && (
<PermissionGate permission="coupons:edit" fallback={null}>
<button
onClick={() => setRevokeConfirm(true)}
className="w-full rounded-lg border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
>
{t('admin.coupons.revoke.button', { count: batch.active_count })}
</button>
</PermissionGate>
)}
{/* Revoke confirmation */}
{revokeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.coupons.revoke.confirmTitle')}
</h3>
<p className="mb-6 text-dark-400">
{t('admin.coupons.revoke.confirmText', { count: batch.active_count })}
</p>
<div className="flex gap-3">
<button onClick={() => setRevokeConfirm(false)} className="btn-secondary flex-1">
{t('admin.coupons.revoke.cancel')}
</button>
<button
onClick={() => revokeMutation.mutate()}
disabled={revokeMutation.isPending}
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
revokeMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('admin.coupons.revoke.confirm')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

190
src/pages/AdminCoupons.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { couponsApi, CouponBatch } from '../api/coupons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { formatPrice, formatShortDate } from '../utils/format';
import {
BackIcon,
PlusIcon,
CheckCircleIcon,
ChartBarIcon,
TagIcon,
TicketIcon,
} from '@/components/icons';
import { StatCard } from '../components/stats';
const PAGE_SIZE = 50;
export default function AdminCoupons() {
const { t } = useTranslation();
const navigate = useNavigate();
const { capabilities } = usePlatform();
const [offset, setOffset] = useState(0);
const { data: batchesData, isLoading } = useQuery({
queryKey: ['admin-coupon-batches', offset],
queryFn: () => couponsApi.getBatches({ limit: PAGE_SIZE, offset }),
});
const batches = batchesData?.items || [];
const total = batchesData?.total || 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.coupons.subtitle')}</p>
</div>
</div>
<button
onClick={() => navigate('/admin/coupons/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.coupons.addBatch')}
</button>
</div>
{/* Stats Overview — the Active/Redeemed/Revoked cards sum only the loaded
page, so show them only when the whole set fits one page; otherwise
they would contradict the global "Batches" total. */}
{batches.length > 0 && total <= PAGE_SIZE && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.coupons.stats.batches')}
value={total}
icon={<TagIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.coupons.stats.active')}
value={batches.reduce((sum, b) => sum + b.active_count, 0)}
icon={<TicketIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.coupons.stats.redeemed')}
value={batches.reduce((sum, b) => sum + b.redeemed_count, 0)}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.coupons.stats.revoked')}
value={batches.reduce((sum, b) => sum + b.revoked_count, 0)}
icon={<ChartBarIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
)}
{/* Batches List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : batches.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.coupons.noBatches')}</p>
</div>
) : (
<div className="space-y-3">
{batches.map((batch: CouponBatch) => (
<button
key={batch.id}
onClick={() => navigate(`/admin/coupons/${batch.id}`)}
className={`w-full rounded-xl border bg-dark-800 p-4 text-left transition-colors hover:border-dark-600 ${
batch.is_revoked ? 'border-dark-700/50 opacity-60' : 'border-dark-700'
}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2 font-medium text-dark-100">
<span className="text-dark-500">#{batch.id}</span>
<span className="truncate">{batch.name}</span>
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{batch.tariff_name || '—'} · {batch.period_days} {t('admin.coupons.days')}
</span>
{batch.is_revoked && (
<span className="rounded bg-error-500/20 px-2 py-0.5 text-xs text-error-400">
{t('admin.coupons.list.revoked')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span className="text-success-400">
{t('admin.coupons.stats.active')}: {batch.active_count}
</span>
<span className="text-accent-400">
{t('admin.coupons.stats.redeemed')}: {batch.redeemed_count}
</span>
{batch.revoked_count > 0 && (
<span className="text-error-400">
{t('admin.coupons.stats.revoked')}: {batch.revoked_count}
</span>
)}
{batch.wholesale_price_kopeks > 0 && (
<span>
{t('admin.coupons.list.price')}:{' '}
{formatPrice(batch.wholesale_price_kopeks, i18n.language)}
</span>
)}
{batch.valid_until && (
<span>
{t('admin.coupons.list.until')}: {formatShortDate(batch.valid_until)}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
)}
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
<button
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
disabled={offset === 0}
className={`btn-secondary min-w-[100px] flex-1 ${offset === 0 ? 'cursor-not-allowed opacity-50' : ''}`}
>
{t('admin.coupons.pagination.prev')}
</button>
<div className="flex-1 text-center">
{t('admin.coupons.pagination.page', { current: currentPage, total: totalPages })}
</div>
<button
onClick={() => setOffset((o) => o + PAGE_SIZE)}
disabled={currentPage >= totalPages}
className={`btn-secondary min-w-[100px] flex-1 ${
currentPage >= totalPages ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('admin.coupons.pagination.next')}
</button>
</div>
)}
</div>
);
}

View File

@@ -187,6 +187,12 @@ const sections: AdminSection[] = [
to: '/admin/promocodes',
permission: 'promocodes:read',
},
{
name: 'admin.nav.coupons',
icon: 'ticket',
to: '/admin/coupons',
permission: 'coupons:read',
},
{
name: 'admin.nav.promoGroups',
icon: 'percent',

176
src/pages/CouponStatus.tsx Normal file
View File

@@ -0,0 +1,176 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { couponsApi, CouponRedeemResponse } from '../api/coupons';
import { useAuthStore } from '../store/auth';
import { formatShortDate } from '../utils/format';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { TicketIcon } from '@/components/icons';
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4 py-10">
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-6 sm:p-8">
{children}
</div>
</div>
);
}
// The redeem endpoint answers 400 with a structured {detail: {code, message}} —
// map the stable machine code to a localized message.
const redeemErrorText = (err: unknown, t: (key: string) => string): string => {
if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail as { code?: string } | string | undefined;
if (detail && typeof detail === 'object' && detail.code) {
const known = ['invalid', 'expired', 'already_redeemed_by_you', 'internal'];
if (known.includes(detail.code)) return t(`coupon.errors.${detail.code}`);
}
}
return t('coupon.errors.generic');
};
export default function CouponStatus() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [redeemed, setRedeemed] = useState<CouponRedeemResponse | null>(null);
const [redeemError, setRedeemError] = useState<string | null>(null);
const {
data: coupon,
isLoading,
error,
} = useQuery({
queryKey: ['coupon-status', token],
queryFn: () => couponsApi.getCouponStatus(token!),
enabled: !!token,
// 404 is the expected answer for an invalid/consumed/expired token — the
// common case for a shared link — so retrying only wastes a request against
// the rate-limited public endpoint.
retry: false,
});
const redeemMutation = useMutation({
mutationFn: () => couponsApi.redeemCoupon(token!),
onSuccess: (result) => {
setRedeemError(null);
setRedeemed(result);
},
onError: (err) => {
setRedeemError(redeemErrorText(err, t));
},
});
if (isLoading) {
return (
<Shell>
<div className="flex justify-center py-8">
<Spinner />
</div>
</Shell>
);
}
if (error || !coupon) {
return (
<Shell>
<div className="text-center">
<h1 className="mb-2 text-lg font-semibold text-dark-100">{t('coupon.notFound.title')}</h1>
<p className="text-sm text-dark-400">{t('coupon.notFound.text')}</p>
</div>
</Shell>
);
}
if (redeemed) {
return (
<Shell>
<div className="text-center">
<div className="mb-4 flex justify-center">
<AnimatedCheckmark />
</div>
<h1 className="mb-2 text-lg font-semibold text-dark-100">
{redeemed.renewed ? t('coupon.success.renewed') : t('coupon.success.activated')}
</h1>
<p className="text-sm text-dark-400">
{redeemed.tariff_name} · {redeemed.period_days} {t('coupon.daysSuffix')}
{redeemed.end_date && (
<>
<br />
{t('coupon.success.until')} {formatShortDate(redeemed.end_date)}
</>
)}
</p>
</div>
</Shell>
);
}
return (
<Shell>
<div className="text-center">
<div className="mb-4 flex justify-center text-accent-400">
<TicketIcon className="h-10 w-10" />
</div>
<h1 className="mb-1 text-lg font-semibold text-dark-100">{t('coupon.title')}</h1>
<p className="mb-6 text-sm text-dark-400">{t('coupon.subtitle')}</p>
<div className="mb-6 space-y-2 rounded-xl border border-dark-800 bg-dark-900 p-4 text-left text-sm">
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('coupon.tariff')}</span>
<span className="font-medium text-dark-100">{coupon.tariff_name}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('coupon.period')}</span>
<span className="font-medium text-dark-100">
{coupon.period_days} {t('coupon.daysSuffix')}
</span>
</div>
{coupon.valid_until && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('coupon.validUntil')}</span>
<span className="font-medium text-dark-100">
{formatShortDate(coupon.valid_until)}
</span>
</div>
)}
</div>
{redeemError && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{redeemError}
</div>
)}
<div className="space-y-3">
{coupon.bot_link && (
<a
href={coupon.bot_link}
target="_blank"
rel="noopener noreferrer"
className="btn-primary block w-full text-center"
>
{t('coupon.openBot')}
</a>
)}
{isAuthenticated ? (
<button
onClick={() => redeemMutation.mutate()}
disabled={redeemMutation.isPending}
className={`btn-secondary w-full ${redeemMutation.isPending ? 'cursor-not-allowed opacity-50' : ''}`}
>
{redeemMutation.isPending ? t('coupon.redeeming') : t('coupon.redeemCabinet')}
</button>
) : (
<p className="text-xs text-dark-500">{t('coupon.needAuth')}</p>
)}
</div>
</div>
</Shell>
);
}

View File

@@ -357,7 +357,7 @@ export default function Wheel() {
// Web-only: synchronously pre-open a tab during the user gesture to dodge the
// popup blocker before the async invoice URL resolves. Not reached in Telegram
// (hasInvoice is true there, so the native invoice flow is used instead).
// eslint-disable-next-line no-restricted-properties
// biome-ignore lint: canonical popup-blocker workaround, see comment above
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
}
starsInvoiceMutation.mutate();

View File

@@ -8,15 +8,15 @@
of the font stacks fixes every flag everywhere without touching any markup
and without affecting any other text. Root fix, not a per-component wrap. */
@font-face {
font-family: 'Twemoji Country Flags';
font-family: "Twemoji Country Flags";
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E007F;
src: url('/fonts/TwemojiCountryFlags.woff2') format('woff2');
src: url("/fonts/TwemojiCountryFlags.woff2") format("woff2");
font-display: swap;
}
/* Animated gradient border — @property enables CSS-only angle animation */
@property --border-angle {
syntax: '<angle>';
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@@ -28,13 +28,13 @@
background:
linear-gradient(var(--_bg), var(--_bg)) padding-box,
conic-gradient(
from var(--border-angle),
var(--_accent) 0%,
transparent 25%,
transparent 75%,
var(--_accent) 100%
)
border-box;
from var(--border-angle),
var(--_accent) 0%,
transparent 25%,
transparent 75%,
var(--_accent) 100%
)
border-box;
animation: border-rotate var(--border-duration, 3s) linear infinite;
}
@@ -295,7 +295,7 @@
/* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */
body::before {
content: '';
content: "";
position: fixed;
inset: 0;
z-index: -1;
@@ -450,7 +450,7 @@ img.twemoji {
/* CSS Spotlight Effect via pseudo-element */
.bento-card-hover::after {
content: '';
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
@@ -625,7 +625,7 @@ img.twemoji {
/* Remove tap highlight and focus outline on mobile */
button,
a,
[role='button'] {
[role="button"] {
-webkit-tap-highlight-color: transparent;
outline: none;
}
@@ -633,13 +633,13 @@ img.twemoji {
/* Only show focus ring for keyboard navigation */
button:focus-visible,
a:focus-visible,
[role='button']:focus-visible {
[role="button"]:focus-visible {
@apply ring-2 ring-accent-500/50 ring-offset-2 ring-offset-dark-900;
}
.light button:focus-visible,
.light a:focus-visible,
.light [role='button']:focus-visible {
.light [role="button"]:focus-visible {
@apply ring-offset-champagne-100;
}
@@ -1181,11 +1181,11 @@ img.twemoji {
/* Glow effects */
.glow-accent {
box-shadow: 0 0 20px theme('colors.accent.500 / 30%');
box-shadow: 0 0 20px theme("colors.accent.500 / 30%");
}
.glow-success {
box-shadow: 0 0 20px theme('colors.success.500 / 30%');
box-shadow: 0 0 20px theme("colors.success.500 / 30%");
}
/* Blur backdrop */
@@ -1229,9 +1229,9 @@ img.twemoji {
.shimmer {
background: linear-gradient(
90deg,
theme('colors.dark.800') 0%,
theme('colors.dark.700') 50%,
theme('colors.dark.800') 100%
theme("colors.dark.800") 0%,
theme("colors.dark.700") 50%,
theme("colors.dark.800") 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
@@ -1240,9 +1240,9 @@ img.twemoji {
.light .shimmer {
background: linear-gradient(
90deg,
theme('colors.champagne.200') 0%,
theme('colors.champagne.300') 50%,
theme('colors.champagne.200') 100%
theme("colors.champagne.200") 0%,
theme("colors.champagne.300") 50%,
theme("colors.champagne.200") 100%
);
}
@@ -1256,7 +1256,7 @@ img.twemoji {
}
/* Color picker range input styling */
input[type='range'] {
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 12px;
@@ -1267,13 +1267,13 @@ input[type='range'] {
/* Desktop: smaller track */
@media (min-width: 640px) {
input[type='range'] {
input[type="range"] {
height: 8px;
border-radius: 4px;
}
}
input[type='range']::-webkit-slider-thumb {
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
@@ -1290,26 +1290,26 @@ input[type='range']::-webkit-slider-thumb {
box-shadow 0.15s ease;
}
input[type='range']::-webkit-slider-thumb:hover {
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow:
0 3px 8px rgba(0, 0, 0, 0.4),
0 0 0 2px rgba(255, 255, 255, 0.2);
}
input[type='range']::-webkit-slider-thumb:active {
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(0.95);
}
/* Desktop: smaller thumb */
@media (min-width: 640px) {
input[type='range']::-webkit-slider-thumb {
input[type="range"]::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
}
input[type='range']::-moz-range-thumb {
input[type="range"]::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
@@ -1324,7 +1324,7 @@ input[type='range']::-moz-range-thumb {
box-shadow 0.15s ease;
}
input[type='range']::-moz-range-thumb:hover {
input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow:
0 3px 8px rgba(0, 0, 0, 0.4),
@@ -1333,25 +1333,25 @@ input[type='range']::-moz-range-thumb:hover {
/* Desktop: smaller thumb */
@media (min-width: 640px) {
input[type='range']::-moz-range-thumb {
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
}
}
/* Hide number input spinners for cleaner look */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
input[type="number"] {
-moz-appearance: textfield;
}
/* Custom checkbox styling - more visible */
input[type='checkbox'] {
input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 1.25rem;
@@ -1367,13 +1367,13 @@ input[type='checkbox'] {
flex-shrink: 0;
}
input[type='checkbox']:checked {
input[type="checkbox"]:checked {
background-color: rgb(var(--color-accent-500));
border-color: rgb(var(--color-accent-500));
}
input[type='checkbox']:checked::after {
content: '';
input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 50%;
top: 45%;
@@ -1384,12 +1384,12 @@ input[type='checkbox']:checked::after {
transform: translate(-50%, -50%) rotate(45deg);
}
input[type='checkbox']:focus {
input[type="checkbox"]:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-accent-500), 0.3);
}
input[type='checkbox']:hover:not(:checked) {
input[type="checkbox"]:hover:not(:checked) {
border-color: rgb(var(--color-dark-500));
background-color: rgb(var(--color-dark-600));
}
@@ -1477,17 +1477,17 @@ input[type='checkbox']:hover:not(:checked) {
background-color: rgb(var(--color-dark-700));
}
.light input[type='checkbox'] {
.light input[type="checkbox"] {
border-color: rgb(var(--color-champagne-400));
background-color: rgb(var(--color-champagne-100));
}
.light input[type='checkbox']:checked {
.light input[type="checkbox"]:checked {
background-color: rgb(var(--color-accent-500));
border-color: rgb(var(--color-accent-500));
}
.light input[type='checkbox']:hover:not(:checked) {
.light input[type="checkbox"]:hover:not(:checked) {
border-color: rgb(var(--color-champagne-500));
background-color: rgb(var(--color-champagne-200));
}
@@ -1523,7 +1523,7 @@ input[type='checkbox']:hover:not(:checked) {
}
.onboarding-tooltip::before {
content: '';
content: "";
@apply absolute h-3 w-3 border-l border-t border-accent-500/30 bg-dark-900;
transform: rotate(45deg);
}

View File

@@ -59,3 +59,21 @@ export function formatPrice(kopeks: number, lang?: string): string {
return `${rounded} ${config.symbol}`;
}
}
const SHORT_DATE_LOCALE_MAP: Record<string, string> = {
ru: 'ru-RU',
en: 'en-US',
zh: 'zh-CN',
fa: 'fa-IR',
};
/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
export function formatShortDate(date: string | null): string {
if (!date) return '-';
const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}