Merge pull request #480 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-07-13 00:04:41 +03:00
committed by GitHub
49 changed files with 2145 additions and 1836 deletions

View File

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

View File

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

View File

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

View File

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

94
biome.json Normal file
View File

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

View File

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

1644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -42,6 +42,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
import DeepLinkRedirect from './pages/DeepLinkRedirect'; import DeepLinkRedirect from './pages/DeepLinkRedirect';
import VerifyEmail from './pages/VerifyEmail'; import VerifyEmail from './pages/VerifyEmail';
import ResetPassword from './pages/ResetPassword'; import ResetPassword from './pages/ResetPassword';
import PublicLegal from './pages/PublicLegal';
import OAuthCallback from './pages/OAuthCallback'; import OAuthCallback from './pages/OAuthCallback';
// Dashboard - load eagerly (default route, LCP-critical) // Dashboard - load eagerly (default route, LCP-critical)
@@ -93,6 +94,10 @@ const AdminBroadcasts = lazyWithRetry(() => import('./pages/AdminBroadcasts'));
const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate')); const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes')); const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate')); const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate'));
const AdminCoupons = lazyWithRetry(() => import('./pages/AdminCoupons'));
const AdminCouponCreate = lazyWithRetry(() => import('./pages/AdminCouponCreate'));
const AdminCouponDetail = lazyWithRetry(() => import('./pages/AdminCouponDetail'));
const CouponStatus = lazyWithRetry(() => import('./pages/CouponStatus'));
const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats')); const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups')); const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate')); const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
@@ -266,6 +271,9 @@ function App() {
<Route path="/auth/oauth/callback" element={<OAuthCallback />} /> <Route path="/auth/oauth/callback" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/reset-password" element={<ResetPassword />} /> <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 <Route
path="/merge/:mergeToken" path="/merge/:mergeToken"
element={ element={
@@ -290,6 +298,14 @@ function App() {
</LazyPage> </LazyPage>
} }
/> />
<Route
path="/coupon/:token"
element={
<LazyPage>
<CouponStatus />
</LazyPage>
}
/>
<Route <Route
path="/buy/:slug" path="/buy/:slug"
element={ element={
@@ -835,6 +851,36 @@ function App() {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="/admin/coupons"
element={
<PermissionRoute permission="coupons:read">
<LazyPage>
<AdminCoupons />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/coupons/create"
element={
<PermissionRoute permission="coupons:create">
<LazyPage>
<AdminCouponCreate />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/coupons/:id"
element={
<PermissionRoute permission="coupons:read">
<LazyPage>
<AdminCouponDetail />
</LazyPage>
</PermissionRoute>
}
/>
<Route <Route
path="/admin/promocodes/:id/stats" path="/admin/promocodes/:id/stats"
element={ element={

View File

@@ -111,6 +111,23 @@ export const adminLegalPagesApi = {
return response.data; 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> => { getRules: async (): Promise<AdminRulesResponse> => {
const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules'); const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
return response.data; return response.data;

View File

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

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

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

View File

@@ -23,6 +23,11 @@ export interface PublicOfferResponse {
updated_at: string | null; updated_at: string | null;
} }
export interface RecurrentPaymentsResponse {
content: string;
updated_at: string | null;
}
export interface ServiceInfo { export interface ServiceInfo {
name: string; name: string;
description: string | null; description: string | null;
@@ -42,6 +47,7 @@ export interface InfoVisibility {
rules: boolean; rules: boolean;
privacy: boolean; privacy: boolean;
offer: boolean; offer: boolean;
recurrent: boolean;
} }
export const infoApi = { export const infoApi = {
@@ -75,6 +81,14 @@ export const infoApi = {
return response.data; 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 // Get service info
getServiceInfo: async (): Promise<ServiceInfo> => { getServiceInfo: async (): Promise<ServiceInfo> => {
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service'); const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '../../api/branding'; import { brandingApi } from '../../api/branding';
import { getApiErrorMessage } from '../../utils/api-error';
import { CheckIcon, CloseIcon, PencilIcon } from './icons'; import { CheckIcon, CloseIcon, PencilIcon } from './icons';
export function AnalyticsTab() { export function AnalyticsTab() {
@@ -31,8 +32,7 @@ export function AnalyticsTab() {
setError(null); setError(null);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; setError(getApiErrorMessage(err, t('common.error')));
setError(detail || t('common.error'));
}, },
}); });

View File

@@ -39,6 +39,7 @@ import {
import { Toggle } from './Toggle'; import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify'; import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
import { getApiErrorMessage } from '../../utils/api-error';
const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<PiCaretDown className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`} /> <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); queryClient.setQueryData(['menu-layout'], data);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } } }; notify.error(getApiErrorMessage(err, t('common.error')));
const detail = error.response?.data?.detail;
notify.error(detail || t('common.error'));
}, },
}); });

View File

@@ -48,6 +48,7 @@ interface Props {
isTelegramWebApp: boolean; isTelegramWebApp: boolean;
onGoBack: () => void; onGoBack: () => void;
onOpenQR?: () => void; onOpenQR?: () => void;
username?: string;
} }
export default function InstallationGuide({ export default function InstallationGuide({
@@ -56,6 +57,7 @@ export default function InstallationGuide({
isTelegramWebApp, isTelegramWebApp,
onGoBack, onGoBack,
onOpenQR, onOpenQR,
username,
}: Props) { }: Props) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { isLight } = useTheme(); const { isLight } = useTheme();
@@ -131,6 +133,7 @@ export default function InstallationGuide({
subscriptionUrl={appConfig.subscriptionUrl} subscriptionUrl={appConfig.subscriptionUrl}
hideLink={appConfig.hideLink} hideLink={appConfig.hideLink}
deepLink={selectedApp?.deepLink} deepLink={selectedApp?.deepLink}
username={username}
getLocalizedText={getLocalizedText} getLocalizedText={getLocalizedText}
getBaseTranslation={getBaseTranslation} getBaseTranslation={getBaseTranslation}
getSvgHtml={getSvgHtml} getSvgHtml={getSvgHtml}
@@ -141,6 +144,7 @@ export default function InstallationGuide({
appConfig.subscriptionUrl, appConfig.subscriptionUrl,
appConfig.hideLink, appConfig.hideLink,
selectedApp?.deepLink, selectedApp?.deepLink,
username,
isLight, isLight,
getLocalizedText, getLocalizedText,
getBaseTranslation, getBaseTranslation,

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon, CopyIcon } from '@/components/icons'; import { CheckIcon, CopyIcon } from '@/components/icons';
import type { RemnawaveButtonClient, LocalizedText } from '@/types'; import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import { collapseDoubledCryptPrefix, hasTemplates, resolveTemplate } from '@/utils/templateEngine';
import { blockButtonClass } from './buttonStyles'; import { blockButtonClass } from './buttonStyles';
// eslint-disable-next-line no-script-url // eslint-disable-next-line no-script-url
@@ -29,6 +30,7 @@ interface BlockButtonsProps {
subscriptionUrl: string | null; subscriptionUrl: string | null;
hideLink?: boolean; hideLink?: boolean;
deepLink?: string | null; deepLink?: string | null;
username?: string;
getLocalizedText: (text: LocalizedText | undefined) => string; getLocalizedText: (text: LocalizedText | undefined) => string;
getBaseTranslation: (key: string, i18nKey: string) => string; getBaseTranslation: (key: string, i18nKey: string) => string;
getSvgHtml: (key: string | undefined) => string; getSvgHtml: (key: string | undefined) => string;
@@ -42,6 +44,7 @@ export function BlockButtons({
subscriptionUrl, subscriptionUrl,
hideLink, hideLink,
deepLink, deepLink,
username,
getLocalizedText, getLocalizedText,
getBaseTranslation, getBaseTranslation,
getSvgHtml, getSvgHtml,
@@ -73,8 +76,17 @@ export function BlockButtons({
) : null; ) : null;
if (btn.type === 'subscriptionLink') { if (btn.type === 'subscriptionLink') {
const url = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl; const raw = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
if (!url || !isValidDeepLink(url)) return null; // 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 ( return (
<button <button
key={idx} key={idx}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1241,6 +1241,7 @@
"campaigns": "Campaigns", "campaigns": "Campaigns",
"promoOffers": "Promo Offers", "promoOffers": "Promo Offers",
"promocodes": "Promo Codes", "promocodes": "Promo Codes",
"coupons": "Coupons",
"promoGroups": "Discount Groups", "promoGroups": "Discount Groups",
"trafficUsage": "Traffic Usage", "trafficUsage": "Traffic Usage",
"updates": "Updates", "updates": "Updates",
@@ -3031,6 +3032,84 @@
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals." "description": "This will remove partner privileges. The partner will no longer earn commissions from referrals."
} }
}, },
"coupons": {
"title": "Coupons",
"subtitle": "Wholesale batches of one-time subscription links",
"addBatch": "Create batch",
"noBatches": "No batches yet",
"days": "d.",
"stats": {
"batches": "Batches",
"active": "Active",
"redeemed": "Redeemed",
"revoked": "Revoked"
},
"list": {
"revoked": "Revoked",
"until": "Until",
"price": "Wholesale"
},
"pagination": {
"prev": "Previous",
"next": "Next",
"page": "Page {{current}} of {{total}}"
},
"form": {
"title": "New coupon batch",
"subtitle": "Every coupon is a one-time subscription link",
"name": "Batch name",
"namePlaceholder": "E.g. the partner name",
"tariff": "Tariff",
"selectTariff": "Select a tariff",
"periodDays": "Subscription days per coupon",
"couponsCount": "Number of coupons (1500)",
"price": "Wholesale price per coupon, ₽",
"priceHint": "Bookkeeping only, 0 — not set",
"validDays": "Coupon lifetime, days",
"validDaysHint": "0 or empty — perpetual",
"create": "Create batch",
"creating": "Creating…",
"cancel": "Cancel"
},
"validation": {
"nameRequired": "Enter the batch name",
"tariffRequired": "Select a tariff",
"periodInvalid": "Subscription days: 1 to 3650",
"countInvalid": "Number of coupons: 1 to 500"
},
"created": {
"title": "Batch created",
"linksLabel": "Links ({{count}})",
"copyAll": "Copy all",
"copied": "Copied",
"download": "Download .txt",
"toList": "Back to batches",
"toBatch": "Open batch"
},
"detail": {
"title": "Batch #{{id}}",
"total": "Total coupons",
"price": "Wholesale price",
"priceTotal": "total",
"validUntil": "Valid until",
"perpetual": "Perpetual",
"createdAt": "Created",
"linksTitle": "Active links ({{count}})"
},
"revoke": {
"button": "Revoke unredeemed ({{count}})",
"confirmTitle": "Revoke the batch?",
"confirmText": "{{count}} unredeemed coupons will be revoked. Their links will stop working. This cannot be undone.",
"confirm": "Revoke",
"cancel": "Cancel",
"success": "Coupons revoked: {{count}}"
},
"errors": {
"loadFailed": "Batch not found",
"createFailed": "Failed to create the batch",
"revokeFailed": "Failed to revoke the batch"
}
},
"promocodes": { "promocodes": {
"title": "Promo Codes", "title": "Promo Codes",
"subtitle": "Manage promo codes and discount groups", "subtitle": "Manage promo codes and discount groups",
@@ -4181,6 +4260,7 @@
"tabs": { "tabs": {
"privacy": "Privacy", "privacy": "Privacy",
"offer": "Offer", "offer": "Offer",
"recurrent": "Recurring Payments",
"rules": "Rules", "rules": "Rules",
"faq": "FAQ" "faq": "FAQ"
}, },
@@ -4195,6 +4275,9 @@
"language": "Content language", "language": "Content language",
"content": "Content", "content": "Content",
"contentPlaceholder": "HTML page 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", "save": "Save",
"saving": "Saving...", "saving": "Saving...",
"saveError": "Failed to save. Please try again.", "saveError": "Failed to save. Please try again.",
@@ -4779,6 +4862,7 @@
"loyalty": "Loyalty", "loyalty": "Loyalty",
"noFaq": "No FAQ available", "noFaq": "No FAQ available",
"noContent": "No content available", "noContent": "No content available",
"documentUnavailable": "This document is not available yet.",
"updatedAt": "Updated", "updatedAt": "Updated",
"noLoyaltyTiers": "Loyalty program is not available yet", "noLoyaltyTiers": "Loyalty program is not available yet",
"yourProgress": "Your Progress", "yourProgress": "Your Progress",
@@ -5084,6 +5168,34 @@
"nMonths": "{{count}} months" "nMonths": "{{count}} months"
} }
}, },
"coupon": {
"title": "Subscription coupon",
"subtitle": "A one-time coupon — redeem it to get or extend a subscription",
"tariff": "Tariff",
"period": "Period",
"daysSuffix": "d.",
"validUntil": "Redeem before",
"openBot": "Redeem in the bot",
"redeemCabinet": "Redeem in the cabinet",
"redeeming": "Redeeming…",
"needAuth": "Sign in to redeem the coupon in the cabinet — or open the link in the bot",
"notFound": {
"title": "Coupon not found",
"text": "The coupon does not exist, was already used or has expired"
},
"success": {
"activated": "Subscription activated!",
"renewed": "Subscription extended!",
"until": "Valid until"
},
"errors": {
"invalid": "Coupon not found or already used",
"expired": "The coupon has expired",
"already_redeemed_by_you": "You have already redeemed this coupon",
"internal": "Server error, try again later",
"generic": "Failed to redeem the coupon"
}
},
"gift": { "gift": {
"title": "Gift Subscription", "title": "Gift Subscription",
"subtitle": "Send a VPN subscription as a gift", "subtitle": "Send a VPN subscription as a gift",

View File

@@ -4006,6 +4006,7 @@
"tabs": { "tabs": {
"privacy": "حریم خصوصی", "privacy": "حریم خصوصی",
"offer": "پیشنهاد", "offer": "پیشنهاد",
"recurrent": "پرداخت‌های تکراری",
"rules": "قوانین", "rules": "قوانین",
"faq": "FAQ" "faq": "FAQ"
}, },

View File

@@ -1263,6 +1263,7 @@
"campaigns": "Кампании", "campaigns": "Кампании",
"promoOffers": "Промопредложения", "promoOffers": "Промопредложения",
"promocodes": "Промокоды", "promocodes": "Промокоды",
"coupons": "Купоны",
"promoGroups": "Группы скидок", "promoGroups": "Группы скидок",
"trafficUsage": "Расход трафика", "trafficUsage": "Расход трафика",
"updates": "Обновления", "updates": "Обновления",
@@ -3433,6 +3434,84 @@
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов." "description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
} }
}, },
"coupons": {
"title": "Купоны",
"subtitle": "Оптовые партии одноразовых ссылок на подписку",
"addBatch": "Создать партию",
"noBatches": "Партий пока нет",
"days": "дн.",
"stats": {
"batches": "Партий",
"active": "Активных",
"redeemed": "Погашено",
"revoked": "Отозвано"
},
"list": {
"revoked": "Отозвана",
"until": "До",
"price": "Опт"
},
"pagination": {
"prev": "Назад",
"next": "Вперёд",
"page": "Стр. {{current}} из {{total}}"
},
"form": {
"title": "Новая партия купонов",
"subtitle": "Каждый купон — одноразовая ссылка на подписку",
"name": "Название партии",
"namePlaceholder": "Например, имя партнёра",
"tariff": "Тариф",
"selectTariff": "Выберите тариф",
"periodDays": "Дней подписки на купон",
"couponsCount": "Количество купонов (1500)",
"price": "Оптовая цена за купон, ₽",
"priceHint": "Только для учёта, 0 — не указывать",
"validDays": "Срок действия купонов, дней",
"validDaysHint": "0 или пусто — бессрочно",
"create": "Создать партию",
"creating": "Создание…",
"cancel": "Отмена"
},
"validation": {
"nameRequired": "Укажите название партии",
"tariffRequired": "Выберите тариф",
"periodInvalid": "Дней подписки: от 1 до 3650",
"countInvalid": "Количество купонов: от 1 до 500"
},
"created": {
"title": "Партия создана",
"linksLabel": "Ссылки ({{count}} шт.)",
"copyAll": "Скопировать все",
"copied": "Скопировано",
"download": "Скачать .txt",
"toList": "К списку партий",
"toBatch": "К партии"
},
"detail": {
"title": "Партия #{{id}}",
"total": "Всего купонов",
"price": "Оптовая цена",
"priceTotal": "итого",
"validUntil": "Действует до",
"perpetual": "Бессрочно",
"createdAt": "Создана",
"linksTitle": "Активные ссылки ({{count}} шт.)"
},
"revoke": {
"button": "Отозвать непогашенные ({{count}})",
"confirmTitle": "Отозвать партию?",
"confirmText": "Будет отозвано {{count}} непогашенных купонов. Их ссылки перестанут работать. Действие необратимо.",
"confirm": "Отозвать",
"cancel": "Отмена",
"success": "Отозвано купонов: {{count}}"
},
"errors": {
"loadFailed": "Партия не найдена",
"createFailed": "Не удалось создать партию",
"revokeFailed": "Не удалось отозвать партию"
}
},
"promocodes": { "promocodes": {
"title": "Промокоды", "title": "Промокоды",
"subtitle": "Управление промокодами и группами скидок", "subtitle": "Управление промокодами и группами скидок",
@@ -4726,6 +4805,7 @@
"tabs": { "tabs": {
"privacy": "Политика конф.", "privacy": "Политика конф.",
"offer": "Оферта", "offer": "Оферта",
"recurrent": "Рекуррентные платежи",
"rules": "Правила", "rules": "Правила",
"faq": "FAQ" "faq": "FAQ"
}, },
@@ -4740,6 +4820,10 @@
"language": "Язык контента", "language": "Язык контента",
"content": "Текст", "content": "Текст",
"contentPlaceholder": "HTML-текст страницы...", "contentPlaceholder": "HTML-текст страницы...",
"charCount": "Символов: {{count}}",
"botSplitEstimate_one": "⚠️ В боте будет показано по частям: ~{{count}} сообщение",
"botSplitEstimate_few": "⚠️ В боте будет показано по частям: ~{{count}} сообщения",
"botSplitEstimate_many": "⚠️ В боте будет показано по частям: ~{{count}} сообщений",
"save": "Сохранить", "save": "Сохранить",
"saving": "Сохранение...", "saving": "Сохранение...",
"saveError": "Не удалось сохранить. Попробуйте ещё раз.", "saveError": "Не удалось сохранить. Попробуйте ещё раз.",
@@ -5333,6 +5417,7 @@
"loyalty": "Статусы", "loyalty": "Статусы",
"noFaq": "Нет вопросов и ответов", "noFaq": "Нет вопросов и ответов",
"noContent": "Нет контента", "noContent": "Нет контента",
"documentUnavailable": "Документ пока недоступен.",
"updatedAt": "Обновлено", "updatedAt": "Обновлено",
"noLoyaltyTiers": "Программа лояльности пока недоступна", "noLoyaltyTiers": "Программа лояльности пока недоступна",
"yourProgress": "Ваш прогресс", "yourProgress": "Ваш прогресс",
@@ -5641,6 +5726,34 @@
"nMonths": "{{count}} мес" "nMonths": "{{count}} мес"
} }
}, },
"coupon": {
"title": "Купон на подписку",
"subtitle": "Одноразовый купон — активируйте, чтобы получить или продлить подписку",
"tariff": "Тариф",
"period": "Срок",
"daysSuffix": "дн.",
"validUntil": "Активировать до",
"openBot": "Активировать в боте",
"redeemCabinet": "Активировать в кабинете",
"redeeming": "Активация…",
"needAuth": "Чтобы активировать купон в кабинете, войдите в аккаунт — или откройте ссылку в боте",
"notFound": {
"title": "Купон не найден",
"text": "Купон не существует, уже использован или истёк"
},
"success": {
"activated": "Подписка активирована!",
"renewed": "Подписка продлена!",
"until": "Действует до"
},
"errors": {
"invalid": "Купон не найден или уже использован",
"expired": "Срок действия купона истёк",
"already_redeemed_by_you": "Вы уже активировали этот купон",
"internal": "Ошибка сервера, попробуйте позже",
"generic": "Не удалось активировать купон"
}
},
"gift": { "gift": {
"title": "Подарить подписку", "title": "Подарить подписку",
"subtitle": "Отправьте VPN-подписку в подарок", "subtitle": "Отправьте VPN-подписку в подарок",

View File

@@ -4005,6 +4005,7 @@
"tabs": { "tabs": {
"privacy": "隐私", "privacy": "隐私",
"offer": "条款", "offer": "条款",
"recurrent": "定期付款",
"rules": "规则", "rules": "规则",
"faq": "FAQ" "faq": "FAQ"
}, },

View File

@@ -0,0 +1,298 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { createNumberInputHandler } from '../utils/inputHelpers';
import { couponsApi, CouponBatchCreated } from '../api/coupons';
import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
import { getApiErrorMessage } from '../utils/api-error';
import { BackIcon, CheckIcon, CopyIcon, DownloadIcon } from '@/components/icons';
const downloadLinksFile = (batch: CouponBatchCreated | { id: number; links: string[] }) => {
const blob = new Blob([batch.links.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `coupons_batch_${batch.id}.txt`;
anchor.click();
URL.revokeObjectURL(url);
};
export default function AdminCouponCreate() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Form state
const [name, setName] = useState('');
const [tariffId, setTariffId] = useState<number | null>(null);
const [periodDays, setPeriodDays] = useState<number | ''>(30);
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null);
// Result state (batch created — show the generated links)
const [created, setCreated] = useState<CouponBatchCreated | null>(null);
const [copied, setCopied] = useState(false);
const { data: tariffsData } = useQuery({
queryKey: ['admin-tariffs-for-coupon'],
queryFn: () => tariffsApi.getTariffs(true),
});
const tariffs = (tariffsData?.tariffs || []).filter((tariff) => tariff.is_active);
const createMutation = useMutation({
mutationFn: couponsApi.createBatch,
onSuccess: (batch) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
setCreated(batch);
},
onError: (err) => {
setServerError(getApiErrorMessage(err, t('admin.coupons.errors.createFailed')));
},
});
const validate = (): boolean => {
const errors: string[] = [];
if (!name.trim()) errors.push('nameRequired');
if (!tariffId) errors.push('tariffRequired');
if (!periodDays || periodDays < 1 || periodDays > 3650) errors.push('periodInvalid');
if (!couponsCount || couponsCount < 1 || couponsCount > 500) errors.push('countInvalid');
setValidationErrors(errors);
return errors.length === 0;
};
const handleSubmit = () => {
setServerError(null);
if (!validate()) return;
createMutation.mutate({
name: name.trim(),
tariff_id: tariffId!,
period_days: Number(periodDays),
coupons_count: Number(couponsCount),
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
valid_days: validDays === '' ? 0 : Number(validDays),
});
};
const handleCopyAll = () => {
if (!created) return;
void copyToClipboard(created.links.join('\n'));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Step 2: the batch is created — show the one-time links
if (created) {
return (
<div className="mx-auto max-w-2xl animate-fade-in">
<div className="mb-6">
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.created.title')}</h1>
<p className="text-sm text-dark-400">
#{created.id} {created.name} · {created.tariff_name} · {created.period_days}{' '}
{t('admin.coupons.days')}
</p>
</div>
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.coupons.created.linksLabel', { count: created.links.length })}
</span>
<div className="flex gap-2">
<button
onClick={handleCopyAll}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('admin.coupons.created.copied') : t('admin.coupons.created.copyAll')}
</button>
<button
onClick={() => downloadLinksFile(created)}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<DownloadIcon />
{t('admin.coupons.created.download')}
</button>
</div>
</div>
<textarea
readOnly
value={created.links.join('\n')}
rows={Math.min(12, created.links.length)}
className="input w-full resize-none font-mono text-xs"
/>
</div>
<div className="flex gap-3">
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
{t('admin.coupons.created.toList')}
</button>
<button
onClick={() => navigate(`/admin/coupons/${created.id}`)}
className="btn-primary flex-1"
>
{t('admin.coupons.created.toBatch')}
</button>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin/coupons')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.form.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.coupons.form.subtitle')}</p>
</div>
</div>
{/* Validation errors */}
{validationErrors.length > 0 && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4">
<ul className="list-inside list-disc space-y-1 text-sm text-error-400">
{validationErrors.map((error) => (
<li key={error}>{t(`admin.coupons.validation.${error}`)}</li>
))}
</ul>
</div>
)}
{serverError && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{serverError}
</div>
)}
<div className="space-y-4 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
{/* Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
placeholder={t('admin.coupons.form.namePlaceholder')}
className="input w-full"
/>
</div>
{/* Tariff */}
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.tariff')}
</label>
<select
value={tariffId ?? ''}
onChange={(e) => setTariffId(e.target.value ? Number(e.target.value) : null)}
className="input w-full"
>
<option value="">{t('admin.coupons.form.selectTariff')}</option>
{tariffs.map((tariff) => (
<option key={tariff.id} value={tariff.id}>
{tariff.name}
</option>
))}
</select>
</div>
{/* Period + count */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.periodDays')}
</label>
<input
type="number"
value={periodDays}
onChange={createNumberInputHandler(setPeriodDays, 1, 3650)}
min={1}
max={3650}
className="input w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.couponsCount')}
</label>
<input
type="number"
value={couponsCount}
onChange={createNumberInputHandler(setCouponsCount, 1, 500)}
min={1}
max={500}
className="input w-full"
/>
</div>
</div>
{/* Wholesale price + validity */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.price')}
</label>
<input
type="number"
value={priceRubles}
onChange={createNumberInputHandler(setPriceRubles, 0)}
min={0}
step="0.01"
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.priceHint')}</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.validDays')}
</label>
<input
type="number"
value={validDays}
onChange={createNumberInputHandler(setValidDays, 0, 3650)}
min={0}
max={3650}
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 border-t border-dark-700 pt-4">
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
{t('admin.coupons.form.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={createMutation.isPending}
className={`btn-primary flex-1 ${createMutation.isPending ? 'cursor-not-allowed opacity-50' : ''}`}
>
{createMutation.isPending
? t('admin.coupons.form.creating')
: t('admin.coupons.form.create')}
</button>
</div>
</div>
</div>
);
}

View File

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

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

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

View File

@@ -14,10 +14,91 @@ import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons'; 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']; 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 { function extractErrorDetail(err: unknown): string | null {
const error = err as { response?: { data?: { detail?: unknown } } }; const error = err as { response?: { data?: { detail?: unknown } } };
const detail = error.response?.data?.detail; const detail = error.response?.data?.detail;
@@ -103,7 +184,7 @@ function DocumentEditor({
kind, kind,
onDirtyChange, onDirtyChange,
}: { }: {
kind: 'privacy-policy' | 'public-offer'; kind: DocumentKind;
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -118,10 +199,7 @@ function DocumentEditor({
const { data, isLoading, isFetching } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', kind], queryKey: ['admin', 'legal-pages', kind],
queryFn: () => queryFn: () => DOCUMENT_API[kind].get(),
kind === 'privacy-policy'
? adminLegalPagesApi.getPrivacyPolicy()
: adminLegalPagesApi.getPublicOffer(),
staleTime: 0, staleTime: 0,
gcTime: 0, gcTime: 0,
}); });
@@ -167,9 +245,7 @@ function DocumentEditor({
is_enabled: enabled[language] ?? false, is_enabled: enabled[language] ?? false,
})), })),
}; };
return kind === 'privacy-policy' return DOCUMENT_API[kind].update(payload);
? adminLegalPagesApi.updatePrivacyPolicy(payload)
: adminLegalPagesApi.updatePublicOffer(payload);
}, },
onSuccess: () => { onSuccess: () => {
haptic.success(); haptic.success();
@@ -216,6 +292,10 @@ function DocumentEditor({
className="input min-h-[320px] w-full font-mono text-sm" className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')} placeholder={t('admin.legalPages.contentPlaceholder')}
/> />
<ContentLengthMeta
text={contents[activeLang] ?? ''}
botVisible={kind !== 'recurrent-payments' && displayMode !== 'web'}
/>
</div> </div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>} {saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button <button
@@ -316,6 +396,7 @@ function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => voi
className="input min-h-[320px] w-full font-mono text-sm" className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')} placeholder={t('admin.legalPages.contentPlaceholder')}
/> />
<ContentLengthMeta text={contents[activeLang] ?? ''} botVisible={displayMode !== 'web'} />
</div> </div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>} {saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button <button
@@ -661,7 +742,7 @@ export default function AdminLegalPages() {
</div> </div>
<div className="flex flex-wrap gap-1"> <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 <button
key={tab} key={tab}
type="button" type="button"
@@ -684,6 +765,9 @@ export default function AdminLegalPages() {
{activeTab === 'offer' && ( {activeTab === 'offer' && (
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} /> <DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
)} )}
{activeTab === 'recurrent' && (
<DocumentEditor key="recurrent" kind="recurrent-payments" onDirtyChange={setDirty} />
)}
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />} {activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />} {activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
</div> </div>

View File

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

View File

@@ -308,9 +308,11 @@ export default function Balance() {
</Card> </Card>
</motion.div> </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 && ( {paymentMethods && paymentMethods.length > 0 && (
<motion.div variants={staggerItem}> <motion.div variants={staggerItem} initial="initial" animate="animate">
<Card> <Card>
<h2 className="mb-4 text-lg font-semibold text-dark-100"> <h2 className="mb-4 text-lg font-semibold text-dark-100">
{t('balance.topUpBalance')} {t('balance.topUpBalance')}
@@ -474,9 +476,10 @@ export default function Balance() {
</Card> </Card>
</motion.div> </motion.div>
{/* Saved Cards Navigation */} {/* Saved Cards Navigation — self-animated: mounts after its query resolves
(see Payment Methods above) */}
{savedCardsData?.recurrent_enabled && ( {savedCardsData?.recurrent_enabled && (
<motion.div variants={staggerItem}> <motion.div variants={staggerItem} initial="initial" animate="animate">
<Card interactive onClick={() => navigate('/balance/saved-cards')}> <Card interactive onClick={() => navigate('/balance/saved-cards')}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import ProviderIcon from '../components/ProviderIcon'; import ProviderIcon from '../components/ProviderIcon';
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth'; 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 { getTelegramInitData } from '../hooks/useTelegramSDK';
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
import { useAuthStore } from '../store/auth'; 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 — // Note: auth user lives in the zustand store, not in React Query —
// the explicit setUser above IS the refresh. No ['user'] query exists. // the explicit setUser above IS the refresh. No ['user'] query exists.
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: unknown) => {
const detail = err.response?.data?.detail; const detail = getApiErrorMessage(err, '');
// The email belongs to another account and merging it requires proving // The email belongs to another account and merging it requires proving
// ownership: the backend asks for THAT account's password (account-takeover // 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. // 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')); setEmailError(t('profile.emailMergePasswordRequired'));
} else if (detail?.includes('already registered')) { } else if (detail.includes('already registered')) {
setEmailError(t('profile.emailAlreadyRegistered')); 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')); setEmailError(t('profile.alreadyHaveEmail'));
} else { } else {
setEmailError(detail || t('common.error')); setEmailError(detail || t('common.error'));
@@ -639,7 +640,11 @@ export default function ConnectedAccounts() {
}; };
return ( 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 <motion.div
key={isLoading ? 'loading' : 'ready'}
className="space-y-6" className="space-y-6"
variants={staggerContainer} variants={staggerContainer}
initial="initial" initial="initial"

View File

@@ -114,12 +114,20 @@ export default function Connection() {
const openDeepLink = useCallback( const openDeepLink = useCallback(
(deepLink: string) => { (deepLink: string) => {
let resolved = deepLink; let resolved = deepLink;
if (isHappCryptolinkMode(connectionLink?.connect_mode) && qrConnectionUrl) { if (hasTemplates(resolved)) {
// In HAPP cryptolink mode always open the resolved happ://crypt... URL.
resolved = qrConnectionUrl;
} else if (hasTemplates(resolved)) {
resolved = resolveUrl(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 isHttpUrl = /^https?:\/\//i.test(resolved);
const finalUrlForTelegram = isHttpUrl const finalUrlForTelegram = isHttpUrl
? resolved ? resolved
@@ -140,7 +148,14 @@ export default function Connection() {
// still navigate normally. (Telegram bug #654272.) // still navigate normally. (Telegram bug #654272.)
openAppScheme(resolved); 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 // Check if any platform has configured apps
@@ -217,6 +232,7 @@ export default function Connection() {
isTelegramWebApp={isTelegramWebApp} isTelegramWebApp={isTelegramWebApp}
onGoBack={handleGoBack} onGoBack={handleGoBack}
onOpenQR={handleOpenQR} onOpenQR={handleOpenQR}
username={user?.username ?? undefined}
/> />
); );
} }

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

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

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { PiCaretDown } from 'react-icons/pi'; import { PiCaretDown } from 'react-icons/pi';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { infoApi, FaqPage, InfoVisibility } from '../api/info'; import { infoApi, FaqPage, InfoVisibility } from '../api/info';
import { formatContent } from '../utils/legalContent';
import { infoPagesApi } from '../api/infoPages'; import { infoPagesApi } from '../api/infoPages';
import { promoApi, LoyaltyTierInfo } from '../api/promo'; import { promoApi, LoyaltyTierInfo } from '../api/promo';
import type { FaqItem, ReplacesTab } from '../api/infoPages'; 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']); 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) // Rich sanitizer for custom InfoPage content (TipTap editor output with media)
const ALLOWED_IFRAME_HOSTS = new Set([ const ALLOWED_IFRAME_HOSTS = new Set([
'www.youtube.com', 'www.youtube.com',
@@ -181,55 +146,6 @@ const sanitizeRichHtml = (html: string): string => {
return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG); 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 --- // --- FAQ Accordion for tab replacements ---
function ReplacementFaqItem({ function ReplacementFaqItem({

View File

@@ -16,6 +16,7 @@ import {
type EmailAuthEnabled, type EmailAuthEnabled,
} from '../api/branding'; } from '../api/branding';
import { getAndClearReturnUrl, tokenStorage } from '../utils/token'; import { getAndClearReturnUrl, tokenStorage } from '../utils/token';
import { getApiErrorMessage } from '../utils/api-error';
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
import { closeMiniApp } from '@telegram-apps/sdk-react'; import { closeMiniApp } from '@telegram-apps/sdk-react';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
@@ -190,9 +191,9 @@ export default function Login() {
navigate(getReturnUrl(), { replace: true }); navigate(getReturnUrl(), { replace: true });
return; return;
} catch (err) { } 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 status = error.response?.status;
const detail = error.response?.data?.detail; const detail = getApiErrorMessage(err, '');
if (import.meta.env.DEV) if (import.meta.env.DEV)
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail); console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
@@ -268,14 +269,14 @@ export default function Login() {
setRegisteredEmail(result.email); setRegisteredEmail(result.email);
} }
} catch (err: unknown) { } 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 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')); setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
} else if (status === 401 || status === 403) { } 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')); setError(t('auth.emailNotVerified', 'Please verify your email first'));
} else { } else {
setError(t('auth.invalidCredentials', 'Invalid email or password')); setError(t('auth.invalidCredentials', 'Invalid email or password'));
@@ -304,9 +305,7 @@ export default function Login() {
await authApi.forgotPassword(forgotPasswordEmail.trim()); await authApi.forgotPassword(forgotPasswordEmail.trim());
setForgotPasswordSent(true); setForgotPasswordSent(true);
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { detail?: string } } }; setForgotPasswordError(getApiErrorMessage(err, t('common.error')));
const detail = error.response?.data?.detail;
setForgotPasswordError(detail || t('common.error'));
} finally { } finally {
setForgotPasswordLoading(false); setForgotPasswordLoading(false);
} }

View File

@@ -9,10 +9,11 @@ import { useAuthStore } from '../store/auth';
import { displayName } from '../utils/displayName'; import { displayName } from '../utils/displayName';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation'; import { isValidEmail } from '../utils/validation';
import { getApiErrorMessage } from '../utils/api-error';
import { import {
notificationsApi, notificationsApi,
NotificationSettings, type NotificationSettings,
NotificationSettingsUpdate, type NotificationSettingsUpdate,
} from '../api/notifications'; } from '../api/notifications';
import { referralApi } from '../api/referral'; import { referralApi } from '../api/referral';
import { brandingApi, type EmailAuthEnabled } from '../api/branding'; import { brandingApi, type EmailAuthEnabled } from '../api/branding';
@@ -112,8 +113,8 @@ export default function Profile() {
setError(null); setError(null);
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC); setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: unknown) => {
setError(err.response?.data?.detail || t('common.error')); setError(getApiErrorMessage(err, t('common.error')));
setSuccess(null); setSuccess(null);
}, },
}); });
@@ -133,13 +134,13 @@ export default function Profile() {
setResendCooldown(UI.RESEND_COOLDOWN_SEC); setResendCooldown(UI.RESEND_COOLDOWN_SEC);
} }
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: unknown) => {
const detail = err.response?.data?.detail; const detail = getApiErrorMessage(err, '');
if (detail?.includes('already registered') || detail?.includes('already in use')) { if (detail.includes('already registered') || detail.includes('already in use')) {
setChangeError(t('profile.changeEmail.emailAlreadyUsed')); setChangeError(t('profile.changeEmail.emailAlreadyUsed'));
} else if (detail?.includes('same as current')) { } else if (detail.includes('same as current')) {
setChangeError(t('profile.changeEmail.sameEmail')); 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')); setChangeError(t('profile.changeEmail.tooManyRequests'));
} else { } else {
setChangeError(detail || t('common.error')); 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 — // Note: auth user lives in the zustand store, not in React Query —
// the explicit setUser above IS the refresh. No ['user'] query exists. // the explicit setUser above IS the refresh. No ['user'] query exists.
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: unknown) => {
const detail = err.response?.data?.detail; const detail = getApiErrorMessage(err, '');
if (detail?.includes('invalid') || detail?.includes('wrong')) { if (detail.includes('invalid') || detail.includes('wrong')) {
setChangeError(t('profile.changeEmail.invalidCode')); setChangeError(t('profile.changeEmail.invalidCode'));
} else if (detail?.includes('expired')) { } else if (detail.includes('expired')) {
setChangeError(t('profile.changeEmail.codeExpired')); setChangeError(t('profile.changeEmail.codeExpired'));
} else { } else {
setChangeError(detail || t('common.error')); setChangeError(detail || t('common.error'));
@@ -324,9 +325,11 @@ export default function Profile() {
</Card> </Card>
</motion.div> </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 && ( {referralTerms?.is_enabled && referralLink && (
<motion.div variants={staggerItem}> <motion.div variants={staggerItem} initial="initial" animate="animate">
<Card> <Card>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2> <h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>

94
src/pages/PublicLegal.tsx Normal file
View 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>
);
}

View File

@@ -73,7 +73,11 @@ export default function SavedCards() {
}; };
return ( 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 <motion.div
key={isLoading ? 'loading' : 'ready'}
className="space-y-6" className="space-y-6"
variants={staggerContainer} variants={staggerContainer}
initial="initial" initial="initial"

View File

@@ -348,9 +348,11 @@ export default function Support() {
</Button> </Button>
</motion.div> </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 && ( {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"> <Card className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800">

View File

@@ -357,7 +357,7 @@ export default function Wheel() {
// Web-only: synchronously pre-open a tab during the user gesture to dodge the // Web-only: synchronously pre-open a tab during the user gesture to dodge the
// popup blocker before the async invoice URL resolves. Not reached in Telegram // popup blocker before the async invoice URL resolves. Not reached in Telegram
// (hasInvoice is true there, so the native invoice flow is used instead). // (hasInvoice is true there, so the native invoice flow is used instead).
// eslint-disable-next-line no-restricted-properties // biome-ignore lint: canonical popup-blocker workaround, see comment above
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null; preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
} }
starsInvoiceMutation.mutate(); starsInvoiceMutation.mutate();
@@ -774,10 +774,13 @@ export default function Wheel() {
> >
<div className="border-t border-dark-700/30 px-4 pb-4 pt-2"> <div className="border-t border-dark-700/30 px-4 pb-4 pt-2">
{history && history.items.length > 0 ? ( {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 <motion.div
variants={staggerContainer} variants={staggerContainer}
initial="hidden" initial="initial"
animate="show" animate="animate"
className="space-y-2" className="space-y-2"
> >
{history.items.map((item: SpinHistoryItem) => ( {history.items.map((item: SpinHistoryItem) => (

View File

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

View File

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

88
src/utils/legalContent.ts Normal file
View 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);
};

View File

@@ -6,6 +6,31 @@ export function hasTemplates(url: string): boolean {
return TEMPLATE_RE.test(url); 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 { interface ResolveContext {
subscriptionUrl: string; subscriptionUrl: string;
username?: string; username?: string;
@@ -14,6 +39,11 @@ interface ResolveContext {
export function resolveTemplate(template: string, ctx: ResolveContext): string { export function resolveTemplate(template: string, ctx: ResolveContext): string {
let result = template; 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); result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
if (ctx.username) { if (ctx.username) {
@@ -21,11 +51,11 @@ export function resolveTemplate(template: string, ctx: ResolveContext): string {
} }
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => { 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, () => { result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl; return cachedHappCryptoLink(ctx.subscriptionUrl, 'v4') ?? ctx.subscriptionUrl;
}); });
return result; return result;