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 - name: Install dependencies
run: npm ci run: npm ci
- name: Run ESLint - name: Run linter
run: npm run lint run: npm run lint
- name: Check formatting - 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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:docker": "vite build", "lint": "biome lint .",
"lint": "eslint .", "lint:fix": "biome lint --write .",
"lint:fix": "eslint . --fix", "format": "biome format --write .",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "biome format .",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "check": "biome check .",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"preview": "vite preview", "preview": "vite preview",
"prepare": "husky" "prepare": "husky"
@@ -75,35 +75,26 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@biomejs/biome": "2.5.3",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@types/react": "^19.2.13", "@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-twemoji": "^0.4.3", "@types/react-twemoji": "^0.4.3",
"@vitejs/plugin-react": "^5.1.2", "@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", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": [ "*.{ts,tsx,json}": [
"eslint --fix", "biome check --write --no-errors-on-unmatched"
"prettier --write"
], ],
"*.{css,json}": [ "*.css": [
"prettier --write" "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 AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes')); const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate')); 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 AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups')); const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate')); const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
@@ -294,6 +298,14 @@ function App() {
</LazyPage> </LazyPage>
} }
/> />
<Route
path="/coupon/:token"
element={
<LazyPage>
<CouponStatus />
</LazyPage>
}
/>
<Route <Route
path="/buy/:slug" path="/buy/:slug"
element={ element={
@@ -839,6 +851,36 @@ function App() {
</PermissionRoute> </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 <Route
path="/admin/promocodes/:id/stats" path="/admin/promocodes/:id/stats"
element={ element={

View File

@@ -284,11 +284,7 @@ export const banSystemApi = {
// Users // Users
getUsers: async ( getUsers: async (
params: { params: { offset?: number; limit?: number; status?: string } = {},
offset?: number;
limit?: number;
status?: string;
} = {},
): Promise<BanUsersListResponse> => { ): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params }); const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
return response.data; return response.data;
@@ -349,11 +345,7 @@ export const banSystemApi = {
// Agents // Agents
getAgents: async ( getAgents: async (
params: { params: { search?: string; health?: string; status?: string } = {},
search?: string;
health?: string;
status?: string;
} = {},
): Promise<BanAgentsListResponse> => { ): Promise<BanAgentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params }); const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
return response.data; 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 export interface CardProps
extends Omit<HTMLMotionProps<'div'>, 'children'>, VariantProps<typeof cardVariants> { extends Omit<HTMLMotionProps<'div'>, 'children'>,
VariantProps<typeof cardVariants> {
children: ReactNode; children: ReactNode;
asChild?: boolean; asChild?: boolean;
haptic?: boolean; haptic?: boolean;

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,8 @@ export {
} from '@radix-ui/react-popover'; } from '@radix-ui/react-popover';
// Content // Content
export interface PopoverContentProps extends ComponentPropsWithoutRef< export interface PopoverContentProps
typeof PopoverPrimitive.Content extends ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
> {
showCloseButton?: boolean; 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'; export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Trigger // Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef< export interface SelectTriggerProps
typeof SelectPrimitive.Trigger extends ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> {
> {
placeholder?: string; placeholder?: string;
} }

View File

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

View File

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

View File

@@ -1241,6 +1241,7 @@
"campaigns": "Campaigns", "campaigns": "Campaigns",
"promoOffers": "Promo Offers", "promoOffers": "Promo Offers",
"promocodes": "Promo Codes", "promocodes": "Promo Codes",
"coupons": "Coupons",
"promoGroups": "Discount Groups", "promoGroups": "Discount Groups",
"trafficUsage": "Traffic Usage", "trafficUsage": "Traffic Usage",
"updates": "Updates", "updates": "Updates",
@@ -3031,6 +3032,84 @@
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals." "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": { "promocodes": {
"title": "Promo Codes", "title": "Promo Codes",
"subtitle": "Manage promo codes and discount groups", "subtitle": "Manage promo codes and discount groups",
@@ -5086,6 +5165,34 @@
"nMonths": "{{count}} months" "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": { "gift": {
"title": "Gift Subscription", "title": "Gift Subscription",
"subtitle": "Send a VPN subscription as a gift", "subtitle": "Send a VPN subscription as a gift",

View File

@@ -1263,6 +1263,7 @@
"campaigns": "Кампании", "campaigns": "Кампании",
"promoOffers": "Промопредложения", "promoOffers": "Промопредложения",
"promocodes": "Промокоды", "promocodes": "Промокоды",
"coupons": "Купоны",
"promoGroups": "Группы скидок", "promoGroups": "Группы скидок",
"trafficUsage": "Расход трафика", "trafficUsage": "Расход трафика",
"updates": "Обновления", "updates": "Обновления",
@@ -3433,6 +3434,84 @@
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов." "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": { "promocodes": {
"title": "Промокоды", "title": "Промокоды",
"subtitle": "Управление промокодами и группами скидок", "subtitle": "Управление промокодами и группами скидок",
@@ -5643,6 +5722,34 @@
"nMonths": "{{count}} мес" "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": { "gift": {
"title": "Подарить подписку", "title": "Подарить подписку",
"subtitle": "Отправьте VPN-подписку в подарок", "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', to: '/admin/promocodes',
permission: 'promocodes:read', permission: 'promocodes:read',
}, },
{
name: 'admin.nav.coupons',
icon: 'ticket',
to: '/admin/coupons',
permission: 'coupons:read',
},
{ {
name: 'admin.nav.promoGroups', name: 'admin.nav.promoGroups',
icon: 'percent', 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 // 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 // popup blocker before the async invoice URL resolves. Not reached in Telegram
// (hasInvoice is true there, so the native invoice flow is used instead). // (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; preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
} }
starsInvoiceMutation.mutate(); starsInvoiceMutation.mutate();

View File

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

View File

@@ -59,3 +59,21 @@ export function formatPrice(kopeks: number, lang?: string): string {
return `${rounded} ${config.symbol}`; 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',
});
}