mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
*.min.js
|
||||
*.min.css
|
||||
package-lock.json
|
||||
13
.prettierrc
13
.prettierrc
@@ -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"]
|
||||
}
|
||||
35
biome-plugins/telegram-webview-guards.grit
Normal file
35
biome-plugins/telegram-webview-guards.grit
Normal 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
94
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
1644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -8,10 +8,11 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && 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"
|
||||
@@ -74,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -42,6 +42,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
|
||||
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import PublicLegal from './pages/PublicLegal';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
// Dashboard - load eagerly (default route, LCP-critical)
|
||||
@@ -93,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'));
|
||||
@@ -266,6 +271,9 @@ function App() {
|
||||
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/offer" element={<PublicLegal doc="offer" />} />
|
||||
<Route path="/privacy" element={<PublicLegal doc="privacy" />} />
|
||||
<Route path="/recurrent-payments" element={<PublicLegal doc="recurrent" />} />
|
||||
<Route
|
||||
path="/merge/:mergeToken"
|
||||
element={
|
||||
@@ -290,6 +298,14 @@ function App() {
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/coupon/:token"
|
||||
element={
|
||||
<LazyPage>
|
||||
<CouponStatus />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/:slug"
|
||||
element={
|
||||
@@ -835,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={
|
||||
|
||||
@@ -111,6 +111,23 @@ export const adminLegalPagesApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRecurrentPayments: async (): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.get<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/recurrent-payments',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRecurrentPayments: async (
|
||||
data: LegalDocumentUpdateRequest,
|
||||
): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.put<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/recurrent-payments',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRules: async (): Promise<AdminRulesResponse> => {
|
||||
const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
|
||||
return response.data;
|
||||
|
||||
@@ -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
112
src/api/coupons.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,11 @@ export interface PublicOfferResponse {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface RecurrentPaymentsResponse {
|
||||
content: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
description: string | null;
|
||||
@@ -42,6 +47,7 @@ export interface InfoVisibility {
|
||||
rules: boolean;
|
||||
privacy: boolean;
|
||||
offer: boolean;
|
||||
recurrent: boolean;
|
||||
}
|
||||
|
||||
export const infoApi = {
|
||||
@@ -75,6 +81,14 @@ export const infoApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recurring-payments document
|
||||
getRecurrentPayments: async (): Promise<RecurrentPaymentsResponse> => {
|
||||
const response = await apiClient.get<RecurrentPaymentsResponse>(
|
||||
'/cabinet/info/recurrent-payments',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get service info
|
||||
getServiceInfo: async (): Promise<ServiceInfo> => {
|
||||
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../../api/branding';
|
||||
import { getApiErrorMessage } from '../../utils/api-error';
|
||||
import { CheckIcon, CloseIcon, PencilIcon } from './icons';
|
||||
|
||||
export function AnalyticsTab() {
|
||||
@@ -31,8 +32,7 @@ export function AnalyticsTab() {
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
setError(detail || t('common.error'));
|
||||
setError(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { Toggle } from './Toggle';
|
||||
import { useNotify } from '../../platform/hooks/useNotify';
|
||||
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
|
||||
import { getApiErrorMessage } from '../../utils/api-error';
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<PiCaretDown className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
@@ -511,9 +512,7 @@ export function MenuEditorTab() {
|
||||
queryClient.setQueryData(['menu-layout'], data);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } } };
|
||||
const detail = error.response?.data?.detail;
|
||||
notify.error(detail || t('common.error'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ interface Props {
|
||||
isTelegramWebApp: boolean;
|
||||
onGoBack: () => void;
|
||||
onOpenQR?: () => void;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function InstallationGuide({
|
||||
@@ -56,6 +57,7 @@ export default function InstallationGuide({
|
||||
isTelegramWebApp,
|
||||
onGoBack,
|
||||
onOpenQR,
|
||||
username,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isLight } = useTheme();
|
||||
@@ -131,6 +133,7 @@ export default function InstallationGuide({
|
||||
subscriptionUrl={appConfig.subscriptionUrl}
|
||||
hideLink={appConfig.hideLink}
|
||||
deepLink={selectedApp?.deepLink}
|
||||
username={username}
|
||||
getLocalizedText={getLocalizedText}
|
||||
getBaseTranslation={getBaseTranslation}
|
||||
getSvgHtml={getSvgHtml}
|
||||
@@ -141,6 +144,7 @@ export default function InstallationGuide({
|
||||
appConfig.subscriptionUrl,
|
||||
appConfig.hideLink,
|
||||
selectedApp?.deepLink,
|
||||
username,
|
||||
isLight,
|
||||
getLocalizedText,
|
||||
getBaseTranslation,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon, CopyIcon } from '@/components/icons';
|
||||
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { collapseDoubledCryptPrefix, hasTemplates, resolveTemplate } from '@/utils/templateEngine';
|
||||
import { blockButtonClass } from './buttonStyles';
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
@@ -29,6 +30,7 @@ interface BlockButtonsProps {
|
||||
subscriptionUrl: string | null;
|
||||
hideLink?: boolean;
|
||||
deepLink?: string | null;
|
||||
username?: string;
|
||||
getLocalizedText: (text: LocalizedText | undefined) => string;
|
||||
getBaseTranslation: (key: string, i18nKey: string) => string;
|
||||
getSvgHtml: (key: string | undefined) => string;
|
||||
@@ -42,6 +44,7 @@ export function BlockButtons({
|
||||
subscriptionUrl,
|
||||
hideLink,
|
||||
deepLink,
|
||||
username,
|
||||
getLocalizedText,
|
||||
getBaseTranslation,
|
||||
getSvgHtml,
|
||||
@@ -73,8 +76,17 @@ export function BlockButtons({
|
||||
) : null;
|
||||
|
||||
if (btn.type === 'subscriptionLink') {
|
||||
const url = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
|
||||
if (!url || !isValidDeepLink(url)) return null;
|
||||
const raw = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
|
||||
// The backend resolves {{HAPP_CRYPT*_LINK}} only when a crypto link is
|
||||
// stored in the DB; since Remnawave 2.8.0 removed the encrypt endpoint,
|
||||
// new users can get the raw template here. Resolve it client-side (the
|
||||
// panel's own subpage does the same) instead of dropping the button.
|
||||
const resolved =
|
||||
raw && subscriptionUrl && hasTemplates(raw)
|
||||
? resolveTemplate(raw, { subscriptionUrl, username })
|
||||
: raw;
|
||||
const url = resolved ? collapseDoubledCryptPrefix(resolved) : resolved;
|
||||
if (!url || hasTemplates(url) || !isValidDeepLink(url)) return null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1241,6 +1241,7 @@
|
||||
"campaigns": "Campaigns",
|
||||
"promoOffers": "Promo Offers",
|
||||
"promocodes": "Promo Codes",
|
||||
"coupons": "Coupons",
|
||||
"promoGroups": "Discount Groups",
|
||||
"trafficUsage": "Traffic Usage",
|
||||
"updates": "Updates",
|
||||
@@ -3031,6 +3032,84 @@
|
||||
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals."
|
||||
}
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Coupons",
|
||||
"subtitle": "Wholesale batches of one-time subscription links",
|
||||
"addBatch": "Create batch",
|
||||
"noBatches": "No batches yet",
|
||||
"days": "d.",
|
||||
"stats": {
|
||||
"batches": "Batches",
|
||||
"active": "Active",
|
||||
"redeemed": "Redeemed",
|
||||
"revoked": "Revoked"
|
||||
},
|
||||
"list": {
|
||||
"revoked": "Revoked",
|
||||
"until": "Until",
|
||||
"price": "Wholesale"
|
||||
},
|
||||
"pagination": {
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {{current}} of {{total}}"
|
||||
},
|
||||
"form": {
|
||||
"title": "New coupon batch",
|
||||
"subtitle": "Every coupon is a one-time subscription link",
|
||||
"name": "Batch name",
|
||||
"namePlaceholder": "E.g. the partner name",
|
||||
"tariff": "Tariff",
|
||||
"selectTariff": "Select a tariff",
|
||||
"periodDays": "Subscription days per coupon",
|
||||
"couponsCount": "Number of coupons (1–500)",
|
||||
"price": "Wholesale price per coupon, ₽",
|
||||
"priceHint": "Bookkeeping only, 0 — not set",
|
||||
"validDays": "Coupon lifetime, days",
|
||||
"validDaysHint": "0 or empty — perpetual",
|
||||
"create": "Create batch",
|
||||
"creating": "Creating…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Enter the batch name",
|
||||
"tariffRequired": "Select a tariff",
|
||||
"periodInvalid": "Subscription days: 1 to 3650",
|
||||
"countInvalid": "Number of coupons: 1 to 500"
|
||||
},
|
||||
"created": {
|
||||
"title": "Batch created",
|
||||
"linksLabel": "Links ({{count}})",
|
||||
"copyAll": "Copy all",
|
||||
"copied": "Copied",
|
||||
"download": "Download .txt",
|
||||
"toList": "Back to batches",
|
||||
"toBatch": "Open batch"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Batch #{{id}}",
|
||||
"total": "Total coupons",
|
||||
"price": "Wholesale price",
|
||||
"priceTotal": "total",
|
||||
"validUntil": "Valid until",
|
||||
"perpetual": "Perpetual",
|
||||
"createdAt": "Created",
|
||||
"linksTitle": "Active links ({{count}})"
|
||||
},
|
||||
"revoke": {
|
||||
"button": "Revoke unredeemed ({{count}})",
|
||||
"confirmTitle": "Revoke the batch?",
|
||||
"confirmText": "{{count}} unredeemed coupons will be revoked. Their links will stop working. This cannot be undone.",
|
||||
"confirm": "Revoke",
|
||||
"cancel": "Cancel",
|
||||
"success": "Coupons revoked: {{count}}"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Batch not found",
|
||||
"createFailed": "Failed to create the batch",
|
||||
"revokeFailed": "Failed to revoke the batch"
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
"title": "Promo Codes",
|
||||
"subtitle": "Manage promo codes and discount groups",
|
||||
@@ -4181,6 +4260,7 @@
|
||||
"tabs": {
|
||||
"privacy": "Privacy",
|
||||
"offer": "Offer",
|
||||
"recurrent": "Recurring Payments",
|
||||
"rules": "Rules",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
@@ -4195,6 +4275,9 @@
|
||||
"language": "Content language",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "HTML page content...",
|
||||
"charCount": "Characters: {{count}}",
|
||||
"botSplitEstimate_one": "⚠️ The bot will show it split into ~{{count}} message",
|
||||
"botSplitEstimate_other": "⚠️ The bot will show it split into ~{{count}} messages",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saveError": "Failed to save. Please try again.",
|
||||
@@ -4779,6 +4862,7 @@
|
||||
"loyalty": "Loyalty",
|
||||
"noFaq": "No FAQ available",
|
||||
"noContent": "No content available",
|
||||
"documentUnavailable": "This document is not available yet.",
|
||||
"updatedAt": "Updated",
|
||||
"noLoyaltyTiers": "Loyalty program is not available yet",
|
||||
"yourProgress": "Your Progress",
|
||||
@@ -5084,6 +5168,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",
|
||||
|
||||
@@ -4006,6 +4006,7 @@
|
||||
"tabs": {
|
||||
"privacy": "حریم خصوصی",
|
||||
"offer": "پیشنهاد",
|
||||
"recurrent": "پرداختهای تکراری",
|
||||
"rules": "قوانین",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
|
||||
@@ -1263,6 +1263,7 @@
|
||||
"campaigns": "Кампании",
|
||||
"promoOffers": "Промопредложения",
|
||||
"promocodes": "Промокоды",
|
||||
"coupons": "Купоны",
|
||||
"promoGroups": "Группы скидок",
|
||||
"trafficUsage": "Расход трафика",
|
||||
"updates": "Обновления",
|
||||
@@ -3433,6 +3434,84 @@
|
||||
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
|
||||
}
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Купоны",
|
||||
"subtitle": "Оптовые партии одноразовых ссылок на подписку",
|
||||
"addBatch": "Создать партию",
|
||||
"noBatches": "Партий пока нет",
|
||||
"days": "дн.",
|
||||
"stats": {
|
||||
"batches": "Партий",
|
||||
"active": "Активных",
|
||||
"redeemed": "Погашено",
|
||||
"revoked": "Отозвано"
|
||||
},
|
||||
"list": {
|
||||
"revoked": "Отозвана",
|
||||
"until": "До",
|
||||
"price": "Опт"
|
||||
},
|
||||
"pagination": {
|
||||
"prev": "Назад",
|
||||
"next": "Вперёд",
|
||||
"page": "Стр. {{current}} из {{total}}"
|
||||
},
|
||||
"form": {
|
||||
"title": "Новая партия купонов",
|
||||
"subtitle": "Каждый купон — одноразовая ссылка на подписку",
|
||||
"name": "Название партии",
|
||||
"namePlaceholder": "Например, имя партнёра",
|
||||
"tariff": "Тариф",
|
||||
"selectTariff": "Выберите тариф",
|
||||
"periodDays": "Дней подписки на купон",
|
||||
"couponsCount": "Количество купонов (1–500)",
|
||||
"price": "Оптовая цена за купон, ₽",
|
||||
"priceHint": "Только для учёта, 0 — не указывать",
|
||||
"validDays": "Срок действия купонов, дней",
|
||||
"validDaysHint": "0 или пусто — бессрочно",
|
||||
"create": "Создать партию",
|
||||
"creating": "Создание…",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Укажите название партии",
|
||||
"tariffRequired": "Выберите тариф",
|
||||
"periodInvalid": "Дней подписки: от 1 до 3650",
|
||||
"countInvalid": "Количество купонов: от 1 до 500"
|
||||
},
|
||||
"created": {
|
||||
"title": "Партия создана",
|
||||
"linksLabel": "Ссылки ({{count}} шт.)",
|
||||
"copyAll": "Скопировать все",
|
||||
"copied": "Скопировано",
|
||||
"download": "Скачать .txt",
|
||||
"toList": "К списку партий",
|
||||
"toBatch": "К партии"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Партия #{{id}}",
|
||||
"total": "Всего купонов",
|
||||
"price": "Оптовая цена",
|
||||
"priceTotal": "итого",
|
||||
"validUntil": "Действует до",
|
||||
"perpetual": "Бессрочно",
|
||||
"createdAt": "Создана",
|
||||
"linksTitle": "Активные ссылки ({{count}} шт.)"
|
||||
},
|
||||
"revoke": {
|
||||
"button": "Отозвать непогашенные ({{count}})",
|
||||
"confirmTitle": "Отозвать партию?",
|
||||
"confirmText": "Будет отозвано {{count}} непогашенных купонов. Их ссылки перестанут работать. Действие необратимо.",
|
||||
"confirm": "Отозвать",
|
||||
"cancel": "Отмена",
|
||||
"success": "Отозвано купонов: {{count}}"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Партия не найдена",
|
||||
"createFailed": "Не удалось создать партию",
|
||||
"revokeFailed": "Не удалось отозвать партию"
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
"title": "Промокоды",
|
||||
"subtitle": "Управление промокодами и группами скидок",
|
||||
@@ -4726,6 +4805,7 @@
|
||||
"tabs": {
|
||||
"privacy": "Политика конф.",
|
||||
"offer": "Оферта",
|
||||
"recurrent": "Рекуррентные платежи",
|
||||
"rules": "Правила",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
@@ -4740,6 +4820,10 @@
|
||||
"language": "Язык контента",
|
||||
"content": "Текст",
|
||||
"contentPlaceholder": "HTML-текст страницы...",
|
||||
"charCount": "Символов: {{count}}",
|
||||
"botSplitEstimate_one": "⚠️ В боте будет показано по частям: ~{{count}} сообщение",
|
||||
"botSplitEstimate_few": "⚠️ В боте будет показано по частям: ~{{count}} сообщения",
|
||||
"botSplitEstimate_many": "⚠️ В боте будет показано по частям: ~{{count}} сообщений",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение...",
|
||||
"saveError": "Не удалось сохранить. Попробуйте ещё раз.",
|
||||
@@ -5333,6 +5417,7 @@
|
||||
"loyalty": "Статусы",
|
||||
"noFaq": "Нет вопросов и ответов",
|
||||
"noContent": "Нет контента",
|
||||
"documentUnavailable": "Документ пока недоступен.",
|
||||
"updatedAt": "Обновлено",
|
||||
"noLoyaltyTiers": "Программа лояльности пока недоступна",
|
||||
"yourProgress": "Ваш прогресс",
|
||||
@@ -5641,6 +5726,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-подписку в подарок",
|
||||
|
||||
@@ -4005,6 +4005,7 @@
|
||||
"tabs": {
|
||||
"privacy": "隐私",
|
||||
"offer": "条款",
|
||||
"recurrent": "定期付款",
|
||||
"rules": "规则",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
|
||||
298
src/pages/AdminCouponCreate.tsx
Normal file
298
src/pages/AdminCouponCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
src/pages/AdminCouponDetail.tsx
Normal file
260
src/pages/AdminCouponDetail.tsx
Normal 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
190
src/pages/AdminCoupons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,91 @@ import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons';
|
||||
|
||||
type LegalTab = 'privacy' | 'offer' | 'rules' | 'faq';
|
||||
type LegalTab = 'privacy' | 'offer' | 'recurrent' | 'rules' | 'faq';
|
||||
|
||||
const DISPLAY_MODES: LegalDisplayMode[] = ['bot', 'web', 'both'];
|
||||
|
||||
type DocumentKind = 'privacy-policy' | 'public-offer' | 'recurrent-payments';
|
||||
|
||||
// Bot chunk/page size (split_telegram_text max_length): longer texts are
|
||||
// delivered by the bot in several messages / paginated pages
|
||||
const TELEGRAM_SPLIT_THRESHOLD = 3500;
|
||||
|
||||
// Mirrors the bot's split_telegram_text greedy paragraph packing to estimate
|
||||
// how many messages/pages the bot will produce for this text
|
||||
function estimateTelegramParts(text: string): number {
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim();
|
||||
if (!normalized) return 0;
|
||||
if (normalized.length <= TELEGRAM_SPLIT_THRESHOLD) return 1;
|
||||
const paragraphs = normalized.split('\n\n').filter((p) => p.trim());
|
||||
let parts = 0;
|
||||
let current = '';
|
||||
for (const paragraph of paragraphs) {
|
||||
const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
|
||||
if (candidate.length <= TELEGRAM_SPLIT_THRESHOLD) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
parts += 1;
|
||||
current = '';
|
||||
}
|
||||
if (paragraph.length <= TELEGRAM_SPLIT_THRESHOLD) {
|
||||
current = paragraph;
|
||||
} else {
|
||||
parts += Math.ceil(paragraph.length / TELEGRAM_SPLIT_THRESHOLD);
|
||||
}
|
||||
}
|
||||
if (current) parts += 1;
|
||||
return parts;
|
||||
}
|
||||
|
||||
function ContentLengthMeta({ text, botVisible }: { text: string; botVisible: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const parts = botVisible ? estimateTelegramParts(text) : 0;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.legalPages.charCount', {
|
||||
count: text.length,
|
||||
defaultValue: 'Characters: {{count}}',
|
||||
})}
|
||||
</span>
|
||||
{parts > 1 && (
|
||||
<span className="text-warning-400">
|
||||
{t('admin.legalPages.botSplitEstimate', {
|
||||
count: parts,
|
||||
defaultValue: '⚠️ The bot will show it split into ~{{count}} messages',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DOCUMENT_API: Record<
|
||||
DocumentKind,
|
||||
{
|
||||
get: () => ReturnType<typeof adminLegalPagesApi.getPrivacyPolicy>;
|
||||
update: (
|
||||
data: Parameters<typeof adminLegalPagesApi.updatePrivacyPolicy>[0],
|
||||
) => ReturnType<typeof adminLegalPagesApi.updatePrivacyPolicy>;
|
||||
}
|
||||
> = {
|
||||
'privacy-policy': {
|
||||
get: adminLegalPagesApi.getPrivacyPolicy,
|
||||
update: adminLegalPagesApi.updatePrivacyPolicy,
|
||||
},
|
||||
'public-offer': {
|
||||
get: adminLegalPagesApi.getPublicOffer,
|
||||
update: adminLegalPagesApi.updatePublicOffer,
|
||||
},
|
||||
'recurrent-payments': {
|
||||
get: adminLegalPagesApi.getRecurrentPayments,
|
||||
update: adminLegalPagesApi.updateRecurrentPayments,
|
||||
},
|
||||
};
|
||||
|
||||
function extractErrorDetail(err: unknown): string | null {
|
||||
const error = err as { response?: { data?: { detail?: unknown } } };
|
||||
const detail = error.response?.data?.detail;
|
||||
@@ -103,7 +184,7 @@ function DocumentEditor({
|
||||
kind,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
kind: 'privacy-policy' | 'public-offer';
|
||||
kind: DocumentKind;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -118,10 +199,7 @@ function DocumentEditor({
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'legal-pages', kind],
|
||||
queryFn: () =>
|
||||
kind === 'privacy-policy'
|
||||
? adminLegalPagesApi.getPrivacyPolicy()
|
||||
: adminLegalPagesApi.getPublicOffer(),
|
||||
queryFn: () => DOCUMENT_API[kind].get(),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
@@ -167,9 +245,7 @@ function DocumentEditor({
|
||||
is_enabled: enabled[language] ?? false,
|
||||
})),
|
||||
};
|
||||
return kind === 'privacy-policy'
|
||||
? adminLegalPagesApi.updatePrivacyPolicy(payload)
|
||||
: adminLegalPagesApi.updatePublicOffer(payload);
|
||||
return DOCUMENT_API[kind].update(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
@@ -216,6 +292,10 @@ function DocumentEditor({
|
||||
className="input min-h-[320px] w-full font-mono text-sm"
|
||||
placeholder={t('admin.legalPages.contentPlaceholder')}
|
||||
/>
|
||||
<ContentLengthMeta
|
||||
text={contents[activeLang] ?? ''}
|
||||
botVisible={kind !== 'recurrent-payments' && displayMode !== 'web'}
|
||||
/>
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
<button
|
||||
@@ -316,6 +396,7 @@ function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => voi
|
||||
className="input min-h-[320px] w-full font-mono text-sm"
|
||||
placeholder={t('admin.legalPages.contentPlaceholder')}
|
||||
/>
|
||||
<ContentLengthMeta text={contents[activeLang] ?? ''} botVisible={displayMode !== 'web'} />
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
<button
|
||||
@@ -661,7 +742,7 @@ export default function AdminLegalPages() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
|
||||
{(['privacy', 'offer', 'recurrent', 'rules', 'faq'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
@@ -684,6 +765,9 @@ export default function AdminLegalPages() {
|
||||
{activeTab === 'offer' && (
|
||||
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
|
||||
)}
|
||||
{activeTab === 'recurrent' && (
|
||||
<DocumentEditor key="recurrent" kind="recurrent-payments" onDirtyChange={setDirty} />
|
||||
)}
|
||||
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
|
||||
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -308,9 +308,11 @@ export default function Balance() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
{/* Payment Methods — self-animated: mounts after its query resolves, when
|
||||
the parent stagger orchestration has already finished and would leave
|
||||
it stuck at opacity 0 */}
|
||||
{paymentMethods && paymentMethods.length > 0 && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('balance.topUpBalance')}
|
||||
@@ -474,9 +476,10 @@ export default function Balance() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Saved Cards Navigation */}
|
||||
{/* Saved Cards Navigation — self-animated: mounts after its query resolves
|
||||
(see Payment Methods above) */}
|
||||
{savedCardsData?.recurrent_enabled && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card interactive onClick={() => navigate('/balance/saved-cards')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
import ProviderIcon from '../components/ProviderIcon';
|
||||
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
@@ -422,16 +423,16 @@ export default function ConnectedAccounts() {
|
||||
// Note: auth user lives in the zustand store, not in React Query —
|
||||
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
||||
},
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||
const detail = err.response?.data?.detail;
|
||||
onError: (err: unknown) => {
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
// The email belongs to another account and merging it requires proving
|
||||
// ownership: the backend asks for THAT account's password (account-takeover
|
||||
// fix). Guide the user to enter it rather than showing a dead-end error.
|
||||
if (detail?.includes('merge')) {
|
||||
if (detail.includes('merge')) {
|
||||
setEmailError(t('profile.emailMergePasswordRequired'));
|
||||
} else if (detail?.includes('already registered')) {
|
||||
} else if (detail.includes('already registered')) {
|
||||
setEmailError(t('profile.emailAlreadyRegistered'));
|
||||
} else if (detail?.includes('already have a verified email')) {
|
||||
} else if (detail.includes('already have a verified email')) {
|
||||
setEmailError(t('profile.alreadyHaveEmail'));
|
||||
} else {
|
||||
setEmailError(detail || t('common.error'));
|
||||
@@ -639,7 +640,11 @@ export default function ConnectedAccounts() {
|
||||
};
|
||||
|
||||
return (
|
||||
// key: remount the container when loading resolves — stagger orchestration
|
||||
// runs once on mount, so provider cards arriving from the API later would
|
||||
// otherwise stay stuck at their initial variant (opacity 0) after a hard refresh
|
||||
<motion.div
|
||||
key={isLoading ? 'loading' : 'ready'}
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
|
||||
@@ -114,12 +114,20 @@ export default function Connection() {
|
||||
const openDeepLink = useCallback(
|
||||
(deepLink: string) => {
|
||||
let resolved = deepLink;
|
||||
if (isHappCryptolinkMode(connectionLink?.connect_mode) && qrConnectionUrl) {
|
||||
// In HAPP cryptolink mode always open the resolved happ://crypt... URL.
|
||||
resolved = qrConnectionUrl;
|
||||
} else if (hasTemplates(resolved)) {
|
||||
if (hasTemplates(resolved)) {
|
||||
resolved = resolveUrl(resolved);
|
||||
}
|
||||
// In HAPP cryptolink mode keep hiding the plain subscription link: force the
|
||||
// happ://crypt... URL only when the button fell back to it or its template
|
||||
// could not be resolved. An explicit link from the panel's Subpage config
|
||||
// (e.g. happ://add/...) wins — admins expect Subpage edits to apply here.
|
||||
if (
|
||||
isHappCryptolinkMode(connectionLink?.connect_mode) &&
|
||||
qrConnectionUrl &&
|
||||
(!resolved || resolved === appConfig?.subscriptionUrl || hasTemplates(resolved))
|
||||
) {
|
||||
resolved = qrConnectionUrl;
|
||||
}
|
||||
const isHttpUrl = /^https?:\/\//i.test(resolved);
|
||||
const finalUrlForTelegram = isHttpUrl
|
||||
? resolved
|
||||
@@ -140,7 +148,14 @@ export default function Connection() {
|
||||
// still navigate normally. (Telegram bug #654272.)
|
||||
openAppScheme(resolved);
|
||||
},
|
||||
[isTelegramWebApp, i18n.language, resolveUrl, connectionLink?.connect_mode, qrConnectionUrl],
|
||||
[
|
||||
isTelegramWebApp,
|
||||
i18n.language,
|
||||
resolveUrl,
|
||||
connectionLink?.connect_mode,
|
||||
qrConnectionUrl,
|
||||
appConfig?.subscriptionUrl,
|
||||
],
|
||||
);
|
||||
|
||||
// Check if any platform has configured apps
|
||||
@@ -217,6 +232,7 @@ export default function Connection() {
|
||||
isTelegramWebApp={isTelegramWebApp}
|
||||
onGoBack={handleGoBack}
|
||||
onOpenQR={handleOpenQR}
|
||||
username={user?.username ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
176
src/pages/CouponStatus.tsx
Normal file
176
src/pages/CouponStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDown } from 'react-icons/pi';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { infoApi, FaqPage, InfoVisibility } from '../api/info';
|
||||
import { formatContent } from '../utils/legalContent';
|
||||
import { infoPagesApi } from '../api/infoPages';
|
||||
import { promoApi, LoyaltyTierInfo } from '../api/promo';
|
||||
import type { FaqItem, ReplacesTab } from '../api/infoPages';
|
||||
@@ -15,42 +16,6 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
|
||||
const BUILTIN_TABS = new Set<string>(['faq', 'rules', 'privacy', 'offer', 'loyalty']);
|
||||
|
||||
// Sanitize HTML content to prevent XSS
|
||||
const sanitizeHtml = (html: string): string => {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'strong',
|
||||
'em',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
's',
|
||||
'del',
|
||||
'ins',
|
||||
'span',
|
||||
'div',
|
||||
'tg-spoiler',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Rich sanitizer for custom InfoPage content (TipTap editor output with media)
|
||||
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||
'www.youtube.com',
|
||||
@@ -181,55 +146,6 @@ const sanitizeRichHtml = (html: string): string => {
|
||||
return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG);
|
||||
};
|
||||
|
||||
// Convert content to formatted HTML (handles Telegram HTML + plain text)
|
||||
const formatContent = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
// Check if content has block-level HTML (full HTML document)
|
||||
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
|
||||
|
||||
if (hasBlockHtml) {
|
||||
return sanitizeHtml(content);
|
||||
}
|
||||
|
||||
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
|
||||
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
|
||||
const result = content
|
||||
.split(/\n\n+/)
|
||||
.map((paragraph) => {
|
||||
const trimmed = paragraph.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
// Check if it's a markdown header
|
||||
if (/^#{1,4}\s/.test(trimmed)) {
|
||||
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
|
||||
const text = trimmed.replace(/^#{1,4}\s*/, '');
|
||||
return `<h${level}>${text}</h${level}>`;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
|
||||
const lines = trimmed.split('\n');
|
||||
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
|
||||
const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
|
||||
const listItems = lines
|
||||
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => `<li>${line}</li>`)
|
||||
.join('');
|
||||
return isOrdered ? `<ol start="${startNum}">${listItems}</ol>` : `<ul>${listItems}</ul>`;
|
||||
}
|
||||
|
||||
// Regular paragraph — single newlines become <br/>
|
||||
const formatted = trimmed.split('\n').join('<br/>');
|
||||
return `<p>${formatted}</p>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return sanitizeHtml(result);
|
||||
};
|
||||
|
||||
// --- FAQ Accordion for tab replacements ---
|
||||
|
||||
function ReplacementFaqItem({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type EmailAuthEnabled,
|
||||
} from '../api/branding';
|
||||
import { getAndClearReturnUrl, tokenStorage } from '../utils/token';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||
import { closeMiniApp } from '@telegram-apps/sdk-react';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
@@ -190,9 +191,9 @@ export default function Login() {
|
||||
navigate(getReturnUrl(), { replace: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||
const error = err as { response?: { status?: number } };
|
||||
const status = error.response?.status;
|
||||
const detail = error.response?.data?.detail;
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
if (import.meta.env.DEV)
|
||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
||||
|
||||
@@ -268,14 +269,14 @@ export default function Login() {
|
||||
setRegisteredEmail(result.email);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||
const error = err as { response?: { status?: number } };
|
||||
const status = error.response?.status;
|
||||
const detail = error.response?.data?.detail;
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
|
||||
if (status === 400 && detail?.includes('already registered')) {
|
||||
if (status === 400 && detail.includes('already registered')) {
|
||||
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
||||
} else if (status === 401 || status === 403) {
|
||||
if (detail?.includes('verify your email')) {
|
||||
if (detail.includes('verify your email')) {
|
||||
setError(t('auth.emailNotVerified', 'Please verify your email first'));
|
||||
} else {
|
||||
setError(t('auth.invalidCredentials', 'Invalid email or password'));
|
||||
@@ -304,9 +305,7 @@ export default function Login() {
|
||||
await authApi.forgotPassword(forgotPasswordEmail.trim());
|
||||
setForgotPasswordSent(true);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||
const detail = error.response?.data?.detail;
|
||||
setForgotPasswordError(detail || t('common.error'));
|
||||
setForgotPasswordError(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setForgotPasswordLoading(false);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import { useAuthStore } from '../store/auth';
|
||||
import { displayName } from '../utils/displayName';
|
||||
import { authApi } from '../api/auth';
|
||||
import { isValidEmail } from '../utils/validation';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import {
|
||||
notificationsApi,
|
||||
NotificationSettings,
|
||||
NotificationSettingsUpdate,
|
||||
type NotificationSettings,
|
||||
type NotificationSettingsUpdate,
|
||||
} from '../api/notifications';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||
@@ -112,8 +113,8 @@ export default function Profile() {
|
||||
setError(null);
|
||||
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||
},
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||
setError(err.response?.data?.detail || t('common.error'));
|
||||
onError: (err: unknown) => {
|
||||
setError(getApiErrorMessage(err, t('common.error')));
|
||||
setSuccess(null);
|
||||
},
|
||||
});
|
||||
@@ -133,13 +134,13 @@ export default function Profile() {
|
||||
setResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||
}
|
||||
},
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||
const detail = err.response?.data?.detail;
|
||||
if (detail?.includes('already registered') || detail?.includes('already in use')) {
|
||||
onError: (err: unknown) => {
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
if (detail.includes('already registered') || detail.includes('already in use')) {
|
||||
setChangeError(t('profile.changeEmail.emailAlreadyUsed'));
|
||||
} else if (detail?.includes('same as current')) {
|
||||
} else if (detail.includes('same as current')) {
|
||||
setChangeError(t('profile.changeEmail.sameEmail'));
|
||||
} else if (detail?.includes('rate limit') || detail?.includes('too many')) {
|
||||
} else if (detail.includes('rate limit') || detail.includes('too many')) {
|
||||
setChangeError(t('profile.changeEmail.tooManyRequests'));
|
||||
} else {
|
||||
setChangeError(detail || t('common.error'));
|
||||
@@ -157,11 +158,11 @@ export default function Profile() {
|
||||
// Note: auth user lives in the zustand store, not in React Query —
|
||||
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
||||
},
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||
const detail = err.response?.data?.detail;
|
||||
if (detail?.includes('invalid') || detail?.includes('wrong')) {
|
||||
onError: (err: unknown) => {
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
if (detail.includes('invalid') || detail.includes('wrong')) {
|
||||
setChangeError(t('profile.changeEmail.invalidCode'));
|
||||
} else if (detail?.includes('expired')) {
|
||||
} else if (detail.includes('expired')) {
|
||||
setChangeError(t('profile.changeEmail.codeExpired'));
|
||||
} else {
|
||||
setChangeError(detail || t('common.error'));
|
||||
@@ -324,9 +325,11 @@ export default function Profile() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Referral Link Widget */}
|
||||
{/* Referral Link Widget — self-animated: mounts after the referral queries
|
||||
resolve, when the parent stagger orchestration has already finished and
|
||||
would leave it stuck at opacity 0 */}
|
||||
{referralTerms?.is_enabled && referralLink && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
|
||||
94
src/pages/PublicLegal.tsx
Normal file
94
src/pages/PublicLegal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import { infoApi } from '../api/info';
|
||||
import { formatContent } from '../utils/legalContent';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
export type PublicLegalDoc = 'offer' | 'privacy' | 'recurrent';
|
||||
|
||||
interface PublicLegalProps {
|
||||
doc: PublicLegalDoc;
|
||||
}
|
||||
|
||||
const DOC_CONFIG: Record<
|
||||
PublicLegalDoc,
|
||||
{
|
||||
queryKey: string;
|
||||
titleKey: string;
|
||||
titleFallback: string;
|
||||
fetch: () => Promise<{ content: string; updated_at: string | null }>;
|
||||
}
|
||||
> = {
|
||||
offer: {
|
||||
queryKey: 'public-offer',
|
||||
titleKey: 'footer.offer',
|
||||
titleFallback: 'Публичная оферта',
|
||||
fetch: infoApi.getPublicOffer,
|
||||
},
|
||||
privacy: {
|
||||
queryKey: 'privacy-policy',
|
||||
titleKey: 'footer.privacy',
|
||||
titleFallback: 'Политика конфиденциальности',
|
||||
fetch: infoApi.getPrivacyPolicy,
|
||||
},
|
||||
recurrent: {
|
||||
queryKey: 'recurrent-payments',
|
||||
titleKey: 'footer.recurrent',
|
||||
titleFallback: 'Рекуррентные платежи',
|
||||
fetch: infoApi.getRecurrentPayments,
|
||||
},
|
||||
};
|
||||
|
||||
// Public, unauthenticated viewer for the legal documents linked from the login
|
||||
// footer. Reads the same public /cabinet/info endpoints the authenticated Info page
|
||||
// uses, so the pages are reachable before login instead of bouncing to /login.
|
||||
export default function PublicLegal({ doc }: PublicLegalProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = DOC_CONFIG[doc];
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['public-legal', config.queryKey],
|
||||
queryFn: config.fetch,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const title = t(config.titleKey, config.titleFallback);
|
||||
|
||||
return (
|
||||
<div className="min-h-viewport bg-dark-950 px-4 py-8 sm:py-12">
|
||||
<div className="fixed right-4 top-4 z-50">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-semibold text-dark-100">{title}</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : isError || !data?.content ? (
|
||||
<div className="bento-card text-dark-400">
|
||||
{t('info.documentUnavailable', 'Документ пока недоступен.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bento-card prose prose-invert max-w-none">
|
||||
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
|
||||
{data.updated_at && (
|
||||
<p className="mt-4 text-xs text-dark-500">
|
||||
{t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Link to="/login" className="btn-secondary">
|
||||
{t('auth.backToLogin', 'Вернуться ко входу')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,11 @@ export default function SavedCards() {
|
||||
};
|
||||
|
||||
return (
|
||||
// key: remount the container when loading resolves — stagger orchestration
|
||||
// runs once on mount, so cards arriving from the API later would otherwise
|
||||
// stay stuck at their initial variant (opacity 0) after a hard refresh
|
||||
<motion.div
|
||||
key={isLoading ? 'loading' : 'ready'}
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
|
||||
@@ -348,9 +348,11 @@ export default function Support() {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact support card for "both" mode */}
|
||||
{/* Contact support card for "both" mode — self-animated: mounts after the
|
||||
config query resolves, when the parent stagger orchestration has already
|
||||
finished and would leave it stuck at opacity 0 */}
|
||||
{supportConfig?.support_type === 'both' && supportConfig.support_username && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800">
|
||||
|
||||
@@ -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();
|
||||
@@ -774,10 +774,13 @@ export default function Wheel() {
|
||||
>
|
||||
<div className="border-t border-dark-700/30 px-4 pb-4 pt-2">
|
||||
{history && history.items.length > 0 ? (
|
||||
// "hidden"/"show" don't exist in staggerContainer/staggerItem
|
||||
// (their keys are initial/animate/exit), so the stagger here
|
||||
// was silently a no-op
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="space-y-2"
|
||||
>
|
||||
{history.items.map((item: SpinHistoryItem) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
88
src/utils/legalContent.ts
Normal file
88
src/utils/legalContent.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Sanitize HTML content to prevent XSS. Shared by the authenticated Info page and
|
||||
// the public legal pages (offer / privacy) reachable from the login footer.
|
||||
export const sanitizeHtml = (html: string): string => {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'strong',
|
||||
'em',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
's',
|
||||
'del',
|
||||
'ins',
|
||||
'span',
|
||||
'div',
|
||||
'tg-spoiler',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Render legal/document content that may be either full block-level HTML or plain
|
||||
// text with Telegram-style inline tags and newline structure.
|
||||
export const formatContent = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
// Check if content has block-level HTML (full HTML document)
|
||||
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
|
||||
|
||||
if (hasBlockHtml) {
|
||||
return sanitizeHtml(content);
|
||||
}
|
||||
|
||||
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
|
||||
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
|
||||
const result = content
|
||||
.split(/\n\n+/)
|
||||
.map((paragraph) => {
|
||||
const trimmed = paragraph.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
// Check if it's a markdown header
|
||||
if (/^#{1,4}\s/.test(trimmed)) {
|
||||
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
|
||||
const text = trimmed.replace(/^#{1,4}\s*/, '');
|
||||
return `<h${level}>${text}</h${level}>`;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
|
||||
const lines = trimmed.split('\n');
|
||||
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
|
||||
const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
|
||||
const listItems = lines
|
||||
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => `<li>${line}</li>`)
|
||||
.join('');
|
||||
return isOrdered ? `<ol start="${startNum}">${listItems}</ol>` : `<ul>${listItems}</ul>`;
|
||||
}
|
||||
|
||||
// Regular paragraph — single newlines become <br/>
|
||||
const formatted = trimmed.split('\n').join('<br/>');
|
||||
return `<p>${formatted}</p>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return sanitizeHtml(result);
|
||||
};
|
||||
@@ -6,6 +6,31 @@ export function hasTemplates(url: string): boolean {
|
||||
return TEMPLATE_RE.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Old bot backends resolve prefix-hardcoded Subpage templates
|
||||
* (happ://crypt4/{{HAPP_CRYPT4_LINK}}) into happ://crypt4/happ://cryptN/... —
|
||||
* strip the outer prefix so the deep link stays openable.
|
||||
*/
|
||||
export function collapseDoubledCryptPrefix(url: string): string {
|
||||
return url.replace(/^happ:\/\/crypt\d+\/(?=happ:\/\/crypt)/i, '');
|
||||
}
|
||||
|
||||
// jsencrypt's RSA-4096 takes up to tens of ms on weak devices and its padding is
|
||||
// random (a new string every call) — cache per subscription URL so render-time
|
||||
// resolution is cheap and stable within a session. Any single ciphertext is valid.
|
||||
const cryptLinkCache = new Map<string, string | null>();
|
||||
|
||||
function cachedHappCryptoLink(url: string, version: 'v3' | 'v4'): string | null {
|
||||
const key = `${version}|${url}`;
|
||||
let link = cryptLinkCache.get(key);
|
||||
if (link === undefined) {
|
||||
if (cryptLinkCache.size >= 100) cryptLinkCache.clear();
|
||||
link = createHappCryptoLink(url, version, true);
|
||||
cryptLinkCache.set(key, link);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
interface ResolveContext {
|
||||
subscriptionUrl: string;
|
||||
username?: string;
|
||||
@@ -14,6 +39,11 @@ interface ResolveContext {
|
||||
export function resolveTemplate(template: string, ctx: ResolveContext): string {
|
||||
let result = template;
|
||||
|
||||
// {{HAPP_CRYPT*_LINK}} resolves to a FULL happ://crypt.../ deep link; when the
|
||||
// template also hardcodes the prefix (happ://crypt4/{{HAPP_CRYPT4_LINK}}),
|
||||
// collapse it so we don't produce happ://crypt4/happ://crypt5/...
|
||||
result = result.replace(/happ:\/\/crypt\d+\/(?=\{\{HAPP_CRYPT[34]_LINK\}\})/gi, '');
|
||||
|
||||
result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
|
||||
|
||||
if (ctx.username) {
|
||||
@@ -21,11 +51,11 @@ export function resolveTemplate(template: string, ctx: ResolveContext): string {
|
||||
}
|
||||
|
||||
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => {
|
||||
return createHappCryptoLink(ctx.subscriptionUrl, 'v3', true) ?? ctx.subscriptionUrl;
|
||||
return cachedHappCryptoLink(ctx.subscriptionUrl, 'v3') ?? ctx.subscriptionUrl;
|
||||
});
|
||||
|
||||
result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
|
||||
return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl;
|
||||
return cachedHappCryptoLink(ctx.subscriptionUrl, 'v4') ?? ctx.subscriptionUrl;
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user