Merge pull request #438 from BEDOLAGA-DEV/chore/impeccable-audit-fixes

chore(impeccable): full audit + critique + 4 god-page decompositions
This commit is contained in:
c0mrade
2026-05-27 10:11:55 +03:00
committed by GitHub
172 changed files with 11778 additions and 9487 deletions

5
.gitignore vendored
View File

@@ -48,3 +48,8 @@ miniapp/
.ai/
CLAUDE.md
**/CLAUDE.md
# Impeccable design context (local only, not for shared repo)
PRODUCT.md
DESIGN.md
DESIGN.json

View File

@@ -39,6 +39,49 @@ export default tseslint.config(
'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,

View File

@@ -200,9 +200,16 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout>;
}
// Suspense wrapper for lazy components
// Suspense + error boundary wrapper for lazy routes. The boundary lives
// OUTSIDE Suspense so chunk-load failures (caught by lazyWithRetry's reload
// path) and render-time exceptions both surface in the page-level fallback
// instead of crashing the entire shell via the top-level boundary.
function LazyPage({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>;
return (
<ErrorBoundary level="page">
<Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>
</ErrorBoundary>
);
}
function BlockingOverlay() {
@@ -264,31 +271,25 @@ function App() {
<Route
path="/buy/success/:token"
element={
<ErrorBoundary level="app">
<LazyPage>
<PurchaseSuccess />
</LazyPage>
</ErrorBoundary>
}
/>
<Route
path="/buy/:slug"
element={
<ErrorBoundary level="app">
<LazyPage>
<QuickPurchase />
</LazyPage>
</ErrorBoundary>
}
/>
<Route
path="/auto-login"
element={
<ErrorBoundary level="app">
<LazyPage>
<AutoLogin />
</LazyPage>
</ErrorBoundary>
}
/>
@@ -387,11 +388,9 @@ function App() {
path="/balance/top-up/result"
element={
<ProtectedRoute withLayout={false}>
<ErrorBoundary level="app">
<LazyPage>
<TopUpResult />
</LazyPage>
</ErrorBoundary>
</ProtectedRoute>
}
/>
@@ -518,25 +517,21 @@ function App() {
<Route
path="/gift"
element={
<ErrorBoundary level="app">
<ProtectedRoute>
<LazyPage>
<GiftSubscription />
</LazyPage>
</ProtectedRoute>
</ErrorBoundary>
}
/>
<Route
path="/gift/result"
element={
<ErrorBoundary level="app">
<ProtectedRoute>
<LazyPage>
<GiftResult />
</LazyPage>
</ProtectedRoute>
</ErrorBoundary>
}
/>
<Route

View File

@@ -41,9 +41,9 @@ export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number];
export const STYLE_OPTIONS = [
{ value: 'default' as const, colorClass: 'bg-dark-500' },
{ value: 'primary' as const, colorClass: 'bg-blue-500' },
{ value: 'primary' as const, colorClass: 'bg-accent-500' },
{ value: 'success' as const, colorClass: 'bg-success-500' },
{ value: 'danger' as const, colorClass: 'bg-red-500' },
{ value: 'danger' as const, colorClass: 'bg-error-500' },
];
const DEFAULT_CONFIG: MenuConfig = { rows: [] };

View File

@@ -281,6 +281,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="360"
value={hsl.h}
onChange={handleHueChange}
aria-label="Hue"
className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{
background:
@@ -301,6 +302,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100"
value={hsl.s}
onChange={handleSaturationChange}
aria-label="Saturation"
className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
@@ -320,6 +322,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100"
value={hsl.l}
onChange={handleLightnessChange}
aria-label="Lightness"
className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,

View File

@@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
if (level === 'app') {
return (
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
<div className="min-h-viewport flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>

View File

@@ -34,15 +34,12 @@ export default function LanguageSwitcher() {
}, []);
const changeLanguage = (code: string) => {
// i18n.ts subscribes to languageChanged and syncs <html lang> + dir
// centrally — no need to set documentElement.dir here.
i18n.changeLanguage(code);
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
setIsOpen(false);
};
useEffect(() => {
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
}, [i18n.language]);
if (availableLanguages.length <= 1) {
return null;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../hooks/useFocusTrap';
interface OnboardingStep {
target: string; // data-onboarding attribute value
@@ -126,6 +127,17 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
onSkip();
};
// Trap focus inside the tooltip while the tour runs; Esc skips it.
// lockScroll stays off so scrollIntoView can bring each target into view.
const trapRef = useFocusTrap<HTMLDivElement>(true, { onEscape: handleSkip, lockScroll: false });
const setTooltipNode = useCallback(
(node: HTMLDivElement | null) => {
tooltipRef.current = node;
trapRef.current = node;
},
[trapRef],
);
// Calculate tooltip position
const getTooltipStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 };
@@ -205,7 +217,11 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
{/* Tooltip */}
<div
ref={tooltipRef}
ref={setTooltipNode}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
aria-describedby="onboarding-desc"
className={`onboarding-tooltip tooltip-${step.placement}`}
style={{
...getTooltipStyle(),
@@ -229,8 +245,12 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
</div>
{/* Content */}
<h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3>
<p className="mb-5 text-sm text-dark-400">{step.description}</p>
<h3 id="onboarding-title" className="mb-2 text-lg font-semibold text-dark-50">
{step.title}
</h3>
<p id="onboarding-desc" className="mb-5 text-sm text-dark-400">
{step.description}
</p>
{/* Actions */}
<div className="flex items-center justify-between">
@@ -259,6 +279,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
{/* Click handler to advance on target click — only when overlay is fully visible */}
{targetRect && isVisible && (
<div
aria-hidden="true"
className="absolute cursor-pointer"
style={{
top: targetRect.top,

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog';
// Helper functions
const formatTimeLeft = (
@@ -88,7 +88,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { dialog, capabilities } = usePlatform();
const confirmDeactivate = useDestructiveConfirm();
const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -160,37 +160,20 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
navigate('/subscription/purchase');
};
const handleDeactivateClick = () => {
const handleDeactivateClick = async () => {
setErrorMessage(null);
setSuccessMessage(null);
if (capabilities.hasNativeDialogs) {
dialog
.popup({
title: t('promo.deactivate.confirmTitle'),
message: t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
buttons: [
{ id: 'cancel', type: 'cancel', text: '' },
{ id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') },
],
})
.then((buttonId) => {
if (buttonId === 'confirm') {
deactivateMutation.mutate();
}
});
} else {
const confirmed = window.confirm(
const confirmed = await confirmDeactivate(
t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
t('promo.deactivate.confirm'),
t('promo.deactivate.confirmTitle'),
);
if (confirmed) {
deactivateMutation.mutate();
}
}
};
// Filter unclaimed and active offers
@@ -252,7 +235,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
</button>
<button
onClick={handleDeactivateClick}
className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-error-500/30 hover:bg-error-500/10 hover:text-error-400"
>
<XCircleIcon />
<span>{t('promo.deactivate.button')}</span>
@@ -282,10 +265,10 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{availableOffers.map((offer) => (
<div
key={offer.id}
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
className="card border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent transition-colors hover:border-warning-500/50"
>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/30 to-amber-500/20">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-warning-500/30 to-warning-500/20">
{getOfferIcon(offer.effect_type, offer.discount_percent)}
</div>
<div className="min-w-0 flex-1">
@@ -308,7 +291,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
<button
onClick={() => handleClaim(offer.id)}
disabled={claimingId === offer.id}
className="group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-orange-500/25 transition-all hover:scale-105 hover:shadow-xl hover:shadow-orange-500/30 active:scale-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 sm:w-auto"
className="group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-warning-500 to-warning-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-warning-500/25 transition-all hover:scale-105 hover:shadow-xl hover:shadow-warning-500/30 active:scale-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 sm:w-auto"
>
{/* Shimmer effect */}
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />

View File

@@ -0,0 +1,72 @@
import { useEffect, useState, type SyntheticEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { usePromptStore } from '../store/promptDialog';
/**
* Global host for usePrompt(). Mount once near the app root.
* Renders an accessible text-input dialog (focus-trapped, Esc/Enter, scroll-locked)
* as a cross-platform replacement for window.prompt, which the Telegram WebView ignores.
*/
export function PromptDialogHost() {
const request = usePromptStore((s) => s.request);
const submit = usePromptStore((s) => s.submit);
const cancel = usePromptStore((s) => s.cancel);
const { t } = useTranslation();
const [value, setValue] = useState('');
const open = request !== null;
const dialogRef = useFocusTrap<HTMLFormElement>(open, { onEscape: cancel });
useEffect(() => {
if (request) setValue(request.initialValue ?? '');
}, [request]);
if (!request) return null;
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed) {
cancel();
return;
}
submit(trimmed);
};
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-dark-950/60" onClick={cancel} aria-hidden="true" />
<form
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={request.title ?? request.label}
onSubmit={handleSubmit}
className="card relative w-full max-w-sm space-y-4"
>
{request.title && <h2 className="text-lg font-semibold text-dark-50">{request.title}</h2>}
<label className="block">
<span className="label">{request.label}</span>
<input
type={request.inputType ?? 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={request.placeholder}
className="input"
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="btn-secondary">
{request.cancelLabel ?? t('common.cancel')}
</button>
<button type="submit" className="btn-primary">
{request.submitLabel ?? t('common.ok', 'OK')}
</button>
</div>
</form>
</div>,
document.body,
);
}

View File

@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { useHaptic } from '@/platform';
// Icons
@@ -93,6 +94,9 @@ export default function SuccessNotificationModal() {
hide();
}, [hide]);
// Esc + scroll-lock are handled by the effects below; the trap only manages focus.
const modalRef = useFocusTrap<HTMLDivElement>(isOpen, { lockScroll: false });
// Escape key to close
useEffect(() => {
if (!isOpen) return;
@@ -201,10 +205,15 @@ export default function SuccessNotificationModal() {
const modalContent = (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
<div className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm" onClick={handleClose} />
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="success-modal-title"
tabIndex={-1}
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
style={{
marginBottom: safeBottom ? `${safeBottom}px` : undefined,
@@ -214,6 +223,7 @@ export default function SuccessNotificationModal() {
{/* Close button */}
<button
onClick={handleClose}
aria-label={t('common.close')}
className="absolute right-3 top-3 z-10 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
<CloseIcon />
@@ -223,8 +233,12 @@ export default function SuccessNotificationModal() {
<div
className={`flex flex-col items-center bg-gradient-to-br ${gradientClass} px-6 pb-8 pt-10`}
>
<div className="mb-4 animate-bounce text-white">{icon}</div>
<h2 className="text-center text-2xl font-bold text-white">{title}</h2>
{/* Use animate-pulse for celebration; bounce easing reads dated and
the lift is the moment, not the bounce. */}
<div className="mb-4 animate-pulse text-white">{icon}</div>
<h2 id="success-modal-title" className="text-center text-2xl font-bold text-white">
{title}
</h2>
{message && <p className="mt-2 text-center text-white/80">{message}</p>}
</div>
@@ -318,7 +332,7 @@ export default function SuccessNotificationModal() {
{isSubscription && (
<button
onClick={handleGoToSubscription}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-accent-500 to-accent-600 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-all hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
>
<RocketIcon />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
@@ -328,7 +342,7 @@ export default function SuccessNotificationModal() {
{isBalanceTopup && (
<button
onClick={handleGoToBalance}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-success-500 to-success-600 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-success-500 active:from-success-600 active:to-success-700"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-success-500 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-colors hover:bg-success-400 active:bg-success-600"
>
<WalletIcon />
<span>{t('successNotification.goToBalance', 'Go to Balance')}</span>
@@ -338,7 +352,7 @@ export default function SuccessNotificationModal() {
{isDevicesPurchased && (
<button
onClick={handleGoToSubscription}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-600 py-3.5 font-bold text-white shadow-lg shadow-blue-500/25 transition-all hover:from-blue-400 hover:to-cyan-500 active:from-blue-600 active:to-cyan-700"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
>
<DevicesIcon />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
@@ -348,7 +362,7 @@ export default function SuccessNotificationModal() {
{isTrafficPurchased && (
<button
onClick={handleGoToSubscription}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-success-500 to-success-600 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-success-500 active:from-success-600 active:to-success-700"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-success-500 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-colors hover:bg-success-400 active:bg-success-600"
>
<TrafficIcon />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>

View File

@@ -422,7 +422,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
if (!botUsername || botUsername === 'your_bot') {
return (
<div className="py-4 text-center text-sm text-gray-500">
<div className="py-4 text-center text-sm text-dark-400">
{t('auth.telegramNotConfigured')}
</div>
);
@@ -499,7 +499,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
</>
) : deepLinkError ? (
<div className="flex flex-col items-center space-y-2">
<p className="text-xs text-red-500">{deepLinkError}</p>
<p className="text-xs text-error-500">{deepLinkError}</p>
<button
type="button"
onClick={startDeepLinkAuth}
@@ -542,14 +542,14 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
</svg>
{oidcLoading ? t('common.loading') : t('auth.loginWithTelegram')}
</button>
{oidcError && <p className="text-xs text-red-500">{oidcError}</p>}
{oidcError && <p className="text-xs text-error-500">{oidcError}</p>}
</div>
) : (
<div ref={containerRef} className="flex justify-center" />
)}
<div className="text-center">
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<p className="mb-2 text-xs text-dark-400">{t('auth.orOpenInApp')}</p>
<a
href={
referralCode

View File

@@ -95,8 +95,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast Container — safe area aware, adaptive width */}
<div className="pointer-events-none fixed left-4 right-4 top-[calc(1rem+env(safe-area-inset-top,0px))] z-[100] flex flex-col gap-3 sm:left-auto sm:right-[calc(1rem+env(safe-area-inset-right,0px))]">
{/* Toast region — safe area aware, adaptive width. role+aria-live lets
screen readers announce arriving toasts without stealing focus. */}
<div
role="region"
aria-label="Notifications"
aria-live="polite"
className="pointer-events-none fixed left-4 right-4 top-[calc(1rem+env(safe-area-inset-top,0px))] z-[100] flex flex-col gap-3 sm:left-auto sm:right-[calc(1rem+env(safe-area-inset-right,0px))]"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
@@ -111,35 +117,51 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
onClose();
};
// Semantic carries through the icon box + a full tinted border. No side
// stripe (was a 4px border-l accent — impeccable absolute ban) and no
// background tint flood — the contained icon is enough at this size.
const typeStyles = {
success: {
border: 'border-l-success-500',
border: 'border-success-500/40',
icon: 'text-success-400',
iconBg: 'bg-success-500/20',
progress: 'bg-success-400',
iconBg: 'bg-success-500/15',
progress: 'bg-success-500',
},
error: {
border: 'border-l-error-500',
border: 'border-error-500/40',
icon: 'text-error-400',
iconBg: 'bg-error-500/20',
progress: 'bg-error-400',
iconBg: 'bg-error-500/15',
progress: 'bg-error-500',
},
warning: {
border: 'border-l-warning-500',
border: 'border-warning-500/40',
icon: 'text-warning-400',
iconBg: 'bg-warning-500/20',
progress: 'bg-warning-400',
iconBg: 'bg-warning-500/15',
progress: 'bg-warning-500',
},
info: {
border: 'border-l-accent-500',
border: 'border-accent-500/40',
icon: 'text-accent-400',
iconBg: 'bg-accent-500/20',
progress: 'bg-accent-400',
iconBg: 'bg-accent-500/15',
progress: 'bg-accent-500',
},
};
const style = typeStyles[toast.type || 'info'];
// Errors interrupt the screen reader; everything else announces politely.
const role = toast.type === 'error' ? 'alert' : 'status';
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const defaultIcons = {
success: (
<svg
@@ -197,13 +219,17 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
return (
<div
className={`pointer-events-auto w-full cursor-pointer border border-l-4 border-dark-700 ${style.border} animate-slide-in-right overflow-hidden rounded-2xl bg-dark-900 shadow-2xl shadow-black/50 backdrop-blur-xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] sm:max-w-sm`}
role={role}
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={`pointer-events-auto w-full cursor-pointer overflow-hidden rounded-2xl border bg-dark-900 shadow-xl shadow-black/30 backdrop-blur-xl ${style.border} animate-slide-in-right transition-transform duration-200 hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950 active:scale-[0.99] sm:max-w-sm`}
>
<div className="relative p-4">
<div className="flex gap-3">
{/* Icon */}
{/* Icon — carries the semantic by itself; the border is a soft echo */}
<div
aria-hidden="true"
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${style.iconBg} ${style.icon}`}
>
{toast.icon || defaultIcons[toast.type || 'info']}
@@ -218,10 +244,12 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
</div>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
{/* Progress bar — visual countdown until auto-dismiss. scaleX animates
on the compositor, no layout reflow. aria-hidden because the visual
timer doesn't carry meaning beyond the toast lifetime. */}
<div aria-hidden="true" className="absolute bottom-0 left-0 right-0 h-0.5 bg-dark-800/50">
<div
className={`h-full w-full ${style.progress} opacity-60`}
className={`h-full w-full ${style.progress} opacity-70`}
style={{
animation: `shrink ${toast.duration}ms linear forwards`,
transformOrigin: 'left',

View File

@@ -73,8 +73,8 @@ export function AnalyticsTab() {
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-warning-500/20 to-error-500/20">
<svg className="h-5 w-5 text-warning-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
</div>
@@ -167,7 +167,7 @@ export function AnalyticsTab() {
<div className="my-5 border-t border-dark-700/30" />
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<svg className="h-4 w-4 text-warning-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z" />
</svg>
<span className="text-sm font-medium text-dark-200">
@@ -226,8 +226,8 @@ export function AnalyticsTab() {
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-success-500/20">
<svg className="h-5 w-5 text-accent-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
</svg>
</div>

View File

@@ -116,6 +116,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
<img
src={brandingApi.getLogoUrl(branding) ?? undefined}
alt="Logo"
loading="lazy"
className="h-full w-full object-cover"
/>
) : (

View File

@@ -11,14 +11,15 @@ import {
} from '../../api/buttonStyles';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
type StyleValue = 'primary' | 'success' | 'danger' | 'default';
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
{ value: 'default', colorClass: 'bg-dark-500' },
{ value: 'primary', colorClass: 'bg-blue-500' },
{ value: 'primary', colorClass: 'bg-accent-500' },
{ value: 'success', colorClass: 'bg-success-500' },
{ value: 'danger', colorClass: 'bg-red-500' },
{ value: 'danger', colorClass: 'bg-error-500' },
];
function labelsEqual(a: Record<string, string>, b: Record<string, string>): boolean {
@@ -42,6 +43,7 @@ export function ButtonsTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
const { confirm: confirmDialog } = useNativeDialog();
const { data: serverStyles } = useQuery({
queryKey: ['button-styles'],
@@ -224,8 +226,8 @@ export function ButtonsTab() {
: cfg.style === 'success'
? 'bg-success-500 text-white'
: cfg.style === 'danger'
? 'bg-red-500 text-white'
: 'bg-blue-500 text-white'
? 'bg-error-500 text-white'
: 'bg-accent-500 text-white'
}`}
>
{t(`admin.buttons.styles.${cfg.style}`)}
@@ -341,8 +343,8 @@ export function ButtonsTab() {
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.buttons.resetConfirm'))) {
onClick={async () => {
if (await confirmDialog(t('admin.buttons.resetConfirm'))) {
resetMutation.mutate();
}
}}

View File

@@ -27,6 +27,7 @@ interface ColoredItemComboboxProps {
onCreateNew: (name: string, color: string) => Promise<ColoredItem>;
onDelete?: (item: ColoredItem) => Promise<void>;
placeholder?: string;
ariaLabel?: string;
isLoading?: boolean;
colors?: string[];
className?: string;
@@ -39,6 +40,7 @@ export function ColoredItemCombobox({
onCreateNew,
onDelete,
placeholder,
ariaLabel,
isLoading = false,
colors = DEFAULT_COLORS,
className,
@@ -190,6 +192,7 @@ export function ColoredItemCombobox({
isOpen ? 'border-accent-500/50' : 'border-dark-700 hover:border-dark-600',
isLoading && 'animate-pulse',
)}
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
@@ -289,11 +292,11 @@ export function ColoredItemCombobox({
type="button"
onClick={(e) => handleDelete(e, item)}
disabled={deletingId === item.id}
className="shrink-0 rounded p-1 text-dark-600 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
className="shrink-0 rounded p-1 text-dark-600 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50"
aria-label={t('news.admin.combobox.delete', { name: item.name })}
>
{deletingId === item.id ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-400 border-t-transparent" />
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-error-400 border-t-transparent" />
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />

View File

@@ -29,6 +29,7 @@ import {
} from '../../api/menuLayout';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -233,7 +234,7 @@ function ButtonChip({
{!isBuiltin && (
<button
onClick={onRemove}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
>
<TrashIcon />
</button>
@@ -422,7 +423,7 @@ function SortableRow({
{!allBuiltin && (
<button
onClick={() => onRemoveRow(row.id)}
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
>
<TrashIcon />
</button>
@@ -532,6 +533,7 @@ export function MenuEditorTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
const { confirm: confirmDialog } = useNativeDialog();
// Fetch config
const {
@@ -794,7 +796,7 @@ export function MenuEditorTab() {
if (isError) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
<div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
{t('common.error')}
</div>
);
@@ -868,8 +870,8 @@ export function MenuEditorTab() {
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
onClick={async () => {
if (await confirmDialog(t('admin.menuEditor.resetConfirm'))) {
resetMutation.mutate();
}
}}

View File

@@ -187,7 +187,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
>
<span className="flex-1 truncate">{currentValue || '-'}</span>
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
<span className="text-dark-500 opacity-0 transition-colors group-focus-within:text-accent-400 group-focus-within:opacity-100 group-hover:text-accent-400 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<EditIcon />
</span>
</button>

View File

@@ -91,7 +91,7 @@ export function SettingsTableRow({
)}
{setting.read_only && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-400">
<span className="flex items-center gap-0.5 rounded-full bg-warning-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400">
{t('admin.settings.badgeEnv')}
<LockIcon className="h-3 w-3" />
</span>
@@ -142,7 +142,7 @@ export function SettingsTableRow({
<button
onClick={onReset}
disabled={isResetting}
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-hover:opacity-100 max-lg:opacity-100"
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100"
title={t('admin.settings.reset')}
aria-label={t('admin.settings.reset')}
>
@@ -157,7 +157,7 @@ export function SettingsTableRow({
'flex-shrink-0 rounded-lg p-1.5 transition-all',
isFavorite
? 'text-warning-400 hover:bg-warning-500/15'
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100 max-lg:opacity-100',
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100',
)}
title={
isFavorite

View File

@@ -0,0 +1,712 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { DropdownSelect } from './DropdownSelect';
import type { UserListItem } from '../../../api/adminUsers';
import type { TariffListItem } from '../../../api/tariffs';
import type { PromoGroup } from '../../../api/promocodes';
import type {
BulkActionType,
BulkActionParams,
BulkActionResult,
BulkProgressEvent,
} from '../../../api/adminBulkActions';
// ──────────────────────────────────────────────────────────────────
// ActionModal
//
// The per-action configuration + execution modal for AdminBulkActions.
// Wraps three internal views:
// - ProgressView (live SSE log + counters while a job streams)
// - ErrorDetails (collapsible per-row error list after completion)
// - the per-action form (days / tariff / traffic / balance /
// promo-group / grant / set-devices / delete-* confirmations)
//
// Self-contained: the parent feeds modal state + reference data
// (tariffs, promo groups, users for active-paid counting) and
// receives onExecute(params) when the operator confirms. No queries
// or mutations here — execution flow lives in the parent's
// handleExecuteAction (which streams progress back into the modal
// via setModal({ ..., progress })).
// ──────────────────────────────────────────────────────────────────
export interface ProgressState {
current: number;
total: number;
successCount: number;
errorCount: number;
log: BulkProgressEvent[];
}
export interface ModalState {
open: boolean;
action: BulkActionType | null;
loading: boolean;
result: BulkActionResult | null;
progress: ProgressState | null;
}
const CheckIcon = () => (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XCloseIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
function ProgressView({ progress }: { progress: ProgressState }) {
const { t } = useTranslation();
const logEndRef = useRef<HTMLDivElement>(null);
const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [progress.log.length]);
return (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-dark-200">
{t('admin.bulkActions.progress.processed', {
current: progress.current,
total: progress.total,
})}
</span>
<span className="font-bold tabular-nums text-accent-400">{percentage}%</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-dark-700/60">
<div
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-success-400" />
<span className="text-sm tabular-nums text-success-400">{progress.successCount}</span>
<span className="text-xs text-dark-500">{t('admin.bulkActions.progress.succeeded')}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-error-400" />
<span className="text-sm tabular-nums text-error-400">{progress.errorCount}</span>
<span className="text-xs text-dark-500">{t('admin.bulkActions.progress.failed')}</span>
</div>
</div>
{progress.log.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800/50 p-3">
<div className="space-y-1">
{progress.log.slice(-10).map((entry, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs">
{entry.success ? (
<span className="mt-0.5 shrink-0 text-success-400" aria-hidden="true">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</span>
) : (
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
)}
<span className="font-mono text-dark-400">
{entry.username ? `@${entry.username}` : `#${entry.user_id}`}
</span>
<span className={entry.success ? 'text-dark-300' : 'text-error-300'}>
{entry.success
? entry.message || t('admin.bulkActions.progress.ok')
: entry.message || entry.error || t('admin.bulkActions.progress.errorGeneric')}
</span>
</div>
))}
<div ref={logEndRef} />
</div>
</div>
)}
</div>
);
}
function ErrorDetails({ result }: { result: BulkActionResult }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
if (result.error_count === 0 || result.errors.length === 0) return null;
return (
<div className="rounded-xl border border-error-500/20 bg-error-500/5">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-4 py-3 text-left"
aria-expanded={expanded}
>
<span className="text-sm font-medium text-error-400">
{t('admin.bulkActions.errors.title', { count: result.error_count })}
</span>
<svg
className={cn(
'h-4 w-4 text-error-400 transition-transform duration-200',
expanded && 'rotate-180',
)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{expanded && (
<div className="max-h-48 overflow-y-auto border-t border-error-500/20 px-4 py-3">
<div className="space-y-2">
{result.errors.map((err, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
<span className="shrink-0 font-mono text-dark-400">
{err.username ? `@${err.username}` : `#${err.user_id}`}
</span>
<span className="text-error-300">{err.error}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export interface ActionModalProps {
modal: ModalState;
selectedCount: number;
tariffs: TariffListItem[];
promoGroups: PromoGroup[];
users: UserListItem[];
selectedSubscriptionIds: number[];
onClose: () => void;
onExecute: (params: BulkActionParams) => void;
}
export function ActionModal({
modal,
selectedCount,
tariffs,
promoGroups,
users,
selectedSubscriptionIds,
onClose,
onExecute,
}: ActionModalProps) {
const { t } = useTranslation();
const [days, setDays] = useState(30);
const [tariffId, setTariffId] = useState<number>(tariffs[0]?.id ?? 0);
const [trafficGb, setTrafficGb] = useState(10);
const [balanceRub, setBalanceRub] = useState(100);
const [promoGroupId, setPromoGroupId] = useState<number | null>(promoGroups[0]?.id ?? null);
const [grantTariffId, setGrantTariffId] = useState<number>(tariffs[0]?.id ?? 0);
const [grantDays, setGrantDays] = useState(30);
const [deviceLimit, setDeviceLimit] = useState(1);
const [deleteFromPanel, setDeleteFromPanel] = useState(true);
const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false);
useEffect(() => {
if (tariffs.length > 0 && tariffId === 0) {
setTariffId(tariffs[0].id);
}
if (tariffs.length > 0 && grantTariffId === 0) {
setGrantTariffId(tariffs[0].id);
}
}, [tariffs, tariffId, grantTariffId]);
useEffect(() => {
if (promoGroups.length > 0 && promoGroupId === null) {
setPromoGroupId(promoGroups[0].id);
}
}, [promoGroups, promoGroupId]);
useEffect(() => {
if (modal.open) {
if (modal.action === 'delete_user') {
setDeleteFromPanel(true);
}
if (modal.action === 'delete_subscription') {
setForceDeleteActivePaid(false);
}
}
}, [modal.open, modal.action]);
// Focus trap + scroll lock + Escape close (only while not loading).
const dialogRef = useFocusTrap<HTMLDivElement>(modal.open, {
onEscape: modal.loading ? undefined : onClose,
});
// Count active paid subscriptions among selected ones
const activePaidCount = useMemo(() => {
if (modal.action !== 'delete_subscription') return 0;
const selectedSubIdSet = new Set(selectedSubscriptionIds);
let count = 0;
for (const user of users) {
for (const sub of user.subscriptions ?? []) {
if (selectedSubIdSet.has(sub.id) && sub.status === 'active' && !sub.is_trial) {
count++;
}
}
}
return count;
}, [modal.action, selectedSubscriptionIds, users]);
const isConfirmDisabled =
modal.loading ||
(modal.action === 'delete_subscription' && activePaidCount > 0 && !forceDeleteActivePaid);
if (!modal.open || !modal.action) return null;
const actionLabelKeys: Record<BulkActionType, string> = {
extend_subscription: 'admin.bulkActions.actions.extend',
add_days: 'admin.bulkActions.actions.extend',
cancel_subscription: 'admin.bulkActions.actions.cancel',
activate_subscription: 'admin.bulkActions.actions.activate',
change_tariff: 'admin.bulkActions.actions.changeTariff',
add_traffic: 'admin.bulkActions.actions.addTraffic',
add_balance: 'admin.bulkActions.actions.addBalance',
assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup',
grant_subscription: 'admin.bulkActions.actions.grantSubscription',
set_devices: 'admin.bulkActions.actions.setDevices',
delete_subscription: 'admin.bulkActions.actions.deleteSubscription',
delete_user: 'admin.bulkActions.actions.deleteUser',
};
const handleSubmit = () => {
const params: BulkActionParams = {};
switch (modal.action) {
case 'extend_subscription':
params.days = days;
break;
case 'change_tariff':
params.tariff_id = tariffId;
break;
case 'add_traffic':
params.traffic_gb = trafficGb;
break;
case 'add_balance':
params.amount_kopeks = Math.round(balanceRub * 100);
break;
case 'assign_promo_group':
params.promo_group_id = promoGroupId;
break;
case 'grant_subscription':
params.tariff_id = grantTariffId;
params.days = grantDays;
break;
case 'set_devices':
params.device_limit = deviceLimit;
break;
case 'delete_subscription':
params.force_delete_active_paid = forceDeleteActivePaid;
break;
case 'delete_user':
params.delete_from_panel = deleteFromPanel;
break;
}
onExecute(params);
};
const handleBackdropClick = () => {
if (!modal.loading) {
onClose();
}
};
const renderInputs = () => {
switch (modal.action) {
case 'extend_subscription':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.days')}
</label>
<input
type="number"
min={1}
max={365}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40"
/>
</div>
);
case 'change_tariff':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.tariff')}
</label>
<DropdownSelect
value={String(tariffId)}
options={tariffs.map((tt) => ({ value: String(tt.id), label: tt.name }))}
onChange={(v) => setTariffId(Number(v))}
/>
</div>
);
case 'add_traffic':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.trafficGb')}
</label>
<input
type="number"
min={1}
max={10000}
value={trafficGb}
onChange={(e) => setTrafficGb(Number(e.target.value))}
className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40"
/>
</div>
);
case 'add_balance':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.balanceRub')}
</label>
<input
type="number"
min={1}
max={100000}
value={balanceRub}
onChange={(e) => setBalanceRub(Number(e.target.value))}
className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40"
/>
</div>
);
case 'assign_promo_group':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.promoGroup')}
</label>
<DropdownSelect
value={promoGroupId !== null ? String(promoGroupId) : 'null'}
options={[
{ value: 'null', label: t('admin.bulkActions.params.removePromoGroup') },
...promoGroups.map((pg) => ({ value: String(pg.id), label: pg.name })),
]}
onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))}
/>
</div>
);
case 'set_devices':
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.deviceLimit')}
</label>
<input
type="number"
min={1}
max={50}
value={deviceLimit}
onChange={(e) => setDeviceLimit(Number(e.target.value))}
className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40"
/>
</div>
);
case 'grant_subscription':
return (
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.tariff')}
</label>
<DropdownSelect
value={String(grantTariffId)}
options={tariffs.map((tt) => ({ value: String(tt.id), label: tt.name }))}
onChange={(v) => setGrantTariffId(Number(v))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.bulkActions.params.days')}
</label>
<input
type="number"
min={1}
max={365}
value={grantDays}
onChange={(e) => setGrantDays(Number(e.target.value))}
className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40"
/>
</div>
<div className="rounded-xl border border-warning-500/20 bg-warning-500/5 px-3 py-2.5">
<p className="text-xs text-warning-400">
{t('admin.bulkActions.grantSubscription.warning')}
</p>
</div>
</div>
);
case 'delete_subscription': {
const totalSelected = selectedSubscriptionIds.length;
return (
<div className="space-y-4">
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteSubscription.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{t('admin.bulkActions.deleteSubscription.hint')}
</p>
</div>
{activePaidCount > 0 && (
<>
<div className="rounded-xl border border-warning-500/20 bg-warning-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-warning-400">
{t('admin.bulkActions.deleteSubscription.activePaidWarning', {
count: activePaidCount,
total: totalSelected,
})}
</p>
</div>
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
<button
onClick={() => setForceDeleteActivePaid((prev) => !prev)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
forceDeleteActivePaid
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
)}
aria-pressed={forceDeleteActivePaid}
>
{forceDeleteActivePaid && <CheckIcon />}
</button>
<span
className={cn(
'text-sm',
forceDeleteActivePaid ? 'font-medium text-error-400' : 'text-dark-400',
)}
>
{t('admin.bulkActions.deleteSubscription.forceDeleteConfirm')}
</span>
</label>
</>
)}
</div>
);
}
case 'delete_user':
return (
<div className="space-y-4">
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteUser.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{t('admin.bulkActions.deleteUser.hint')}
</p>
</div>
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
<button
onClick={() => setDeleteFromPanel((prev) => !prev)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
deleteFromPanel
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
)}
aria-pressed={deleteFromPanel}
>
{deleteFromPanel && <CheckIcon />}
</button>
<span
className={cn(
'text-sm',
deleteFromPanel ? 'font-medium text-error-400' : 'text-dark-400',
)}
>
{t('admin.bulkActions.deleteUser.deleteFromPanel')}
</span>
</label>
</div>
);
default:
return null;
}
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{
paddingTop: 'max(1rem, env(safe-area-inset-top))',
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
paddingLeft: 'max(1rem, env(safe-area-inset-left))',
paddingRight: 'max(1rem, env(safe-area-inset-right))',
}}
>
<div
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
onClick={handleBackdropClick}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="bulk-action-modal-title"
tabIndex={-1}
className="relative max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-dark-700 bg-dark-900 p-6 shadow-2xl"
>
<div className="mb-5 flex items-center justify-between">
<h3 id="bulk-action-modal-title" className="text-lg font-bold text-dark-100">
{t(actionLabelKeys[modal.action])}
</h3>
{!modal.loading && (
<button
onClick={onClose}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
aria-label={t('common.close')}
>
<XCloseIcon />
</button>
)}
</div>
{modal.loading && modal.progress ? (
<div className="space-y-4">
<ProgressView progress={modal.progress} />
<p className="text-center text-xs text-dark-500">
{t('admin.bulkActions.progress.doNotClose')}
</p>
</div>
) : modal.result ? (
<div className="space-y-4">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-3 text-center text-sm font-semibold text-dark-100">
{t('admin.bulkActions.complete')}
</div>
<div className="mb-3 text-center text-sm">
<span className="font-medium text-success-400">
{t('admin.bulkActions.progress.summarySuccess', {
count: modal.result.success_count,
})}
</span>
{modal.result.error_count > 0 && (
<>
<span className="mx-1.5 text-dark-600" aria-hidden="true">
/
</span>
<span className="font-medium text-error-400">
{t('admin.bulkActions.progress.summaryErrors', {
count: modal.result.error_count,
})}
</span>
</>
)}
</div>
<div className="flex justify-center gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-success-400">
{modal.result.success_count}
</div>
<div className="text-xs text-dark-400">
{t('admin.bulkActions.successCount', { count: modal.result.success_count })}
</div>
</div>
{modal.result.error_count > 0 && (
<div className="text-center">
<div className="text-2xl font-bold text-error-400">
{modal.result.error_count}
</div>
<div className="text-xs text-dark-400">
{t('admin.bulkActions.errorCount', { count: modal.result.error_count })}
</div>
</div>
)}
</div>
</div>
<ErrorDetails result={modal.result} />
<button
onClick={onClose}
className="w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
>
{t('common.close')}
</button>
</div>
) : (
<>
<div className="mb-4 rounded-xl border border-accent-500/20 bg-accent-500/5 px-4 py-3">
<p className="text-sm text-dark-200">
{t('admin.bulkActions.selectedCount', { count: selectedCount })}
</p>
</div>
<div className="mb-6">{renderInputs()}</div>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={modal.loading}
className="min-h-[44px] flex-1 rounded-xl border border-dark-700 bg-dark-800 px-4 py-2.5 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
>
{t('admin.bulkActions.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={isConfirmDisabled}
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{t('admin.bulkActions.confirm')}
</button>
</div>
</>
)}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,47 @@
import { cn } from '@/lib/utils';
// ──────────────────────────────────────────────────────────────────
// DropdownSelect — small native-<select> wrapper used both by the
// AdminBulkActions filter row and the ActionModal's tariff/promo
// picker. Shared primitive so both consumers stay on the same
// chrome (border, focus ring, chevron).
// ──────────────────────────────────────────────────────────────────
export const ChevronDownIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
export interface DropdownOption {
value: string;
label: string;
}
export interface DropdownSelectProps {
value: string;
options: DropdownOption[];
onChange: (v: string) => void;
className?: string;
}
export function DropdownSelect({ value, options, onChange, className }: DropdownSelectProps) {
return (
<div className={cn('relative', className)}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full appearance-none rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 pr-8 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-dark-500">
<ChevronDownIcon />
</div>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from './DropdownSelect';
import { isSubscriptionLevelAction } from './actionTargets';
import type { BulkActionType } from '../../../api/adminBulkActions';
// ──────────────────────────────────────────────────────────────────
// FloatingActionBar
//
// Bottom-dock selection toolbar for AdminBulkActions. Shows selected
// user/subscription counts, the "select-all subscriptions" toggle
// (multi-tariff only), and the action dropdown grouped into
// subscription-level vs user-level when multi-tariff is on.
//
// Pure presentational — parent owns selection state, supplies onAction
// (opens the modal with the picked action type) and onToggleAll.
// ──────────────────────────────────────────────────────────────────
interface ActionConfig {
type: BulkActionType;
labelKey: string;
icon: ReactNode;
colorClass: string;
}
export interface FloatingActionBarProps {
selectedUserCount: number;
selectedSubscriptionCount: number;
isMultiTariff: boolean;
totalVisibleSubscriptionCount: number;
onAction: (type: BulkActionType) => void;
onToggleAllSubscriptions: () => void;
}
export function FloatingActionBar({
selectedUserCount,
selectedSubscriptionCount,
isMultiTariff,
totalVisibleSubscriptionCount,
onAction,
onToggleAllSubscriptions,
}: FloatingActionBarProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0;
if (!hasAnySelection) return null;
const actions: ActionConfig[] = [
{
type: 'extend_subscription',
labelKey: 'admin.bulkActions.actions.extend',
icon: <span aria-hidden="true">+</span>,
colorClass: 'text-success-400 hover:bg-success-500/10',
},
{
type: 'activate_subscription',
labelKey: 'admin.bulkActions.actions.activate',
icon: <span aria-hidden="true">+</span>,
colorClass: 'text-success-400 hover:bg-success-500/10',
},
{
type: 'cancel_subscription',
labelKey: 'admin.bulkActions.actions.cancel',
icon: <span aria-hidden="true">-</span>,
colorClass: 'text-error-400 hover:bg-error-500/10',
},
{
type: 'change_tariff',
labelKey: 'admin.bulkActions.actions.changeTariff',
icon: <span aria-hidden="true">~</span>,
colorClass: 'text-accent-400 hover:bg-accent-500/10',
},
{
type: 'add_traffic',
labelKey: 'admin.bulkActions.actions.addTraffic',
icon: <span aria-hidden="true">+</span>,
colorClass: 'text-accent-400 hover:bg-accent-500/10',
},
{
type: 'set_devices',
labelKey: 'admin.bulkActions.actions.setDevices',
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
),
colorClass: 'text-accent-400 hover:bg-accent-500/10',
},
{
type: 'delete_subscription',
labelKey: 'admin.bulkActions.actions.deleteSubscription',
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
),
colorClass: 'text-error-400 hover:bg-error-500/10',
},
{
type: 'add_balance',
labelKey: 'admin.bulkActions.actions.addBalance',
icon: <span aria-hidden="true">$</span>,
colorClass: 'text-warning-400 hover:bg-warning-500/10',
},
{
type: 'grant_subscription',
labelKey: 'admin.bulkActions.actions.grantSubscription',
icon: <span aria-hidden="true">+</span>,
colorClass: 'text-success-400 hover:bg-success-500/10',
},
{
type: 'assign_promo_group',
labelKey: 'admin.bulkActions.actions.assignPromoGroup',
icon: <span aria-hidden="true">%</span>,
colorClass: 'text-accent-300 hover:bg-accent-300/10',
},
{
type: 'delete_user',
labelKey: 'admin.bulkActions.actions.deleteUser',
icon: (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M22 10.5h-6m-8.25-4.5a3.75 3.75 0 117.5 0 3.75 3.75 0 01-7.5 0zM1.5 21a8.25 8.25 0 0115 0"
/>
</svg>
),
colorClass: 'text-error-400 hover:bg-error-500/10',
},
];
return (
<div className="fixed inset-x-0 bottom-0 z-[9999] flex justify-center px-4 pb-[max(5rem,calc(4.5rem+env(safe-area-inset-bottom)))]">
<div
ref={menuRef}
className="relative flex w-full max-w-2xl items-center gap-3 rounded-2xl border border-dark-700/60 bg-dark-800/80 px-5 py-3 shadow-2xl backdrop-blur-xl"
>
<div className="flex items-center gap-2">
{selectedUserCount > 0 && (
<div className="flex items-center gap-1.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-500/20 text-sm font-bold text-accent-400">
{selectedUserCount}
</div>
<span className="hidden text-xs font-medium text-dark-300 sm:inline">
{t('admin.bulkActions.usersSelected', { count: selectedUserCount })}
</span>
</div>
)}
{isMultiTariff && selectedSubscriptionCount > 0 && (
<div className="flex items-center gap-1.5">
{selectedUserCount > 0 && <div className="mx-1 h-4 w-px bg-dark-700" />}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/20 text-sm font-bold text-success-400">
{selectedSubscriptionCount}
</div>
<span className="hidden text-xs font-medium text-dark-300 sm:inline">
{t('admin.bulkActions.subscriptionsSelected', {
count: selectedSubscriptionCount,
})}
</span>
</div>
)}
</div>
{isMultiTariff && totalVisibleSubscriptionCount > 0 && (
<>
<div className="mx-1 h-6 w-px bg-dark-700" />
<button
onClick={onToggleAllSubscriptions}
className="shrink-0 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
{selectedSubscriptionCount === totalVisibleSubscriptionCount &&
selectedSubscriptionCount > 0
? t('admin.bulkActions.deselectAllSubs')
: t('admin.bulkActions.selectAllSubs')}
</button>
</>
)}
<div className="mx-2 h-6 w-px bg-dark-700" />
<div className="relative ml-auto">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-xl bg-accent-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
>
{t('common.actions')}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-xl border border-dark-700 bg-dark-800 py-1.5 shadow-2xl">
{isMultiTariff && (
<div className="border-b border-dark-700/50 px-4 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.bulkActions.subscriptionTarget')}
</span>
</div>
)}
{actions
.filter((a) => isSubscriptionLevelAction(a.type))
.map((a) => {
const count = isMultiTariff ? selectedSubscriptionCount : selectedUserCount;
const disabled = count === 0;
return (
<button
key={a.type}
onClick={() => {
if (disabled) return;
setOpen(false);
onAction(a.type);
}}
disabled={disabled}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
)}
>
<span className="border-current/20 bg-current/5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-xs font-bold">
{a.icon}
</span>
{t(a.labelKey)}
</button>
);
})}
{isMultiTariff && (
<div className="border-b border-t border-dark-700/50 px-4 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.bulkActions.userTarget')}
</span>
</div>
)}
{actions
.filter((a) => !isSubscriptionLevelAction(a.type))
.map((a) => {
const disabled = selectedUserCount === 0;
return (
<button
key={a.type}
onClick={() => {
if (disabled) return;
setOpen(false);
onAction(a.type);
}}
disabled={disabled}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
)}
>
<span className="border-current/20 bg-current/5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-xs font-bold">
{a.icon}
</span>
{t(a.labelKey)}
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from './DropdownSelect';
// ──────────────────────────────────────────────────────────────────
// MultiSelectDropdown
//
// Pop-over multi-select used by AdminBulkActions filters (tariffs,
// statuses, nodes, etc.). Closes on outside click; provides
// select-all / deselect-all helpers. Pure controlled component.
// ──────────────────────────────────────────────────────────────────
export interface MultiSelectOption {
value: number;
label: string;
}
export interface MultiSelectDropdownProps {
options: MultiSelectOption[];
selected: number[];
onChange: (ids: number[]) => void;
placeholder: string;
className?: string;
}
export function MultiSelectDropdown({
options,
selected,
onChange,
placeholder,
className,
}: MultiSelectDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const buttonLabel = useMemo(() => {
if (selected.length === 0) return placeholder;
if (selected.length <= 2) {
return selected
.map((id) => options.find((o) => o.value === id)?.label)
.filter(Boolean)
.join(', ');
}
return t('admin.bulkActions.filters.tariffsSelected', { count: selected.length });
}, [selected, options, placeholder, t]);
const handleToggle = (value: number) => {
if (selected.includes(value)) {
onChange(selected.filter((id) => id !== value));
} else {
onChange([...selected, value]);
}
};
const handleSelectAll = () => {
onChange(options.map((o) => o.value));
};
const handleDeselectAll = () => {
onChange([]);
};
return (
<div ref={containerRef} className={cn('relative', className)}>
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
'flex w-full items-center justify-between rounded-xl border bg-dark-800 px-3 py-2.5 text-left text-sm outline-none transition-colors',
open
? 'border-accent-500/40 shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]'
: 'border-dark-700',
selected.length > 0 ? 'text-dark-100' : 'text-dark-500',
)}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="truncate">{buttonLabel}</span>
<div
className={cn('ml-2 shrink-0 text-dark-500 transition-transform', open && 'rotate-180')}
>
<ChevronDownIcon />
</div>
</button>
{open && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-2xl">
<div className="flex items-center gap-1 border-b border-dark-700/50 px-3 py-1.5">
<button
type="button"
onClick={handleSelectAll}
className="text-xs font-medium text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.bulkActions.filters.selectAll')}
</button>
<span className="text-dark-600">/</span>
<button
type="button"
onClick={handleDeselectAll}
className="text-xs font-medium text-dark-400 transition-colors hover:text-dark-300"
>
{t('admin.bulkActions.filters.deselectAll')}
</button>
</div>
{options.map((option) => {
const isChecked = selected.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => handleToggle(option.value)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors hover:bg-dark-700/50"
role="option"
aria-selected={isChecked}
>
<div
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-all duration-150',
isChecked
? 'border-accent-500 bg-accent-500'
: 'border-dark-500 bg-dark-700/60',
)}
>
{isChecked && (
<svg
className="h-2.5 w-2.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={4}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</div>
<span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}>
{option.label}
</span>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { UserListItemSubscription } from '../../../api/adminUsers';
// ──────────────────────────────────────────────────────────────────
// SubscriptionSubRow + StatusBadge
//
// SubscriptionSubRow: the expanded per-user secondary <tr> rendered
// under a user row when the operator drills into multi-subscription
// users. Shows tariff, status, days remaining, traffic bar, devices.
//
// StatusBadge: the pill used both inside the sub-row and in the
// primary user-table column (subscription_status). Co-located here
// because both consumers want the same visual contract.
// ──────────────────────────────────────────────────────────────────
export interface SubscriptionSubRowProps {
subscription: UserListItemSubscription;
isSelected: boolean;
onToggleSelect: () => void;
isMultiTariff: boolean;
}
export function SubscriptionSubRow({
subscription,
isSelected,
onToggleSelect,
isMultiTariff,
}: SubscriptionSubRowProps) {
const { t } = useTranslation();
const days = subscription.days_remaining;
const daysColor =
days === 0 ? 'text-error-400' : days <= 7 ? 'text-warning-400' : 'text-success-400';
const trafficPercent =
subscription.traffic_limit_gb > 0
? Math.min(100, (subscription.traffic_used_gb / subscription.traffic_limit_gb) * 100)
: 0;
const trafficBarColor =
trafficPercent >= 90
? 'bg-error-400'
: trafficPercent >= 70
? 'bg-warning-400'
: 'bg-accent-400';
return (
<tr
className={cn(
'border-b border-dark-700/30 transition-colors',
isSelected ? 'bg-accent-500/8' : 'bg-dark-850/40 hover:bg-dark-800/60',
)}
>
<td className="px-3 py-2">
{isMultiTariff && (
<div className="flex items-center justify-center">
<button
onClick={onToggleSelect}
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
isSelected
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
)}
aria-label={
isSelected
? t('admin.bulkActions.deselectUser', { name: subscription.tariff_name || '' })
: t('admin.bulkActions.selectUser', { name: subscription.tariff_name || '' })
}
>
{isSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={4}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
</div>
)}
</td>
<td colSpan={7} className="px-3 py-2">
<div className="flex items-center gap-3 pl-9">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-dark-500" aria-hidden="true">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</span>
<span className="text-xs font-semibold text-dark-200">
{subscription.tariff_name || '—'}
</span>
</div>
<StatusBadge status={subscription.status} />
<span className={cn('text-xs font-medium tabular-nums', daysColor)}>
{days}
<span className="ml-0.5 text-[10px] font-normal text-dark-500">
{t('admin.bulkActions.daysUnit')}
</span>
</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] tabular-nums text-dark-400">
{subscription.traffic_used_gb.toFixed(1)} {t('admin.bulkActions.trafficOf')}{' '}
{subscription.traffic_limit_gb} {t('admin.bulkActions.trafficGbUnit')}
</span>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-dark-700/60">
<div
className={cn('h-full rounded-full transition-all duration-300', trafficBarColor)}
style={{ width: `${trafficPercent}%` }}
/>
</div>
</div>
<span className="flex items-center gap-1 text-xs text-dark-400">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
{subscription.device_limit}
</span>
</div>
</td>
</tr>
);
}
export function StatusBadge({ status }: { status: string | null }) {
const { t } = useTranslation();
const config: Record<string, { class: string; labelKey: string }> = {
active: {
class: 'border-success-500/30 bg-success-500/15 text-success-400',
labelKey: 'admin.bulkActions.statuses.active',
},
expired: {
class: 'border-error-500/30 bg-error-500/15 text-error-400',
labelKey: 'admin.bulkActions.statuses.expired',
},
trial: {
class: 'border-warning-500/30 bg-warning-500/15 text-warning-400',
labelKey: 'admin.bulkActions.statuses.trial',
},
limited: {
class: 'border-warning-500/30 bg-warning-500/15 text-warning-400',
labelKey: 'admin.bulkActions.statuses.limited',
},
disabled: {
class: 'border-dark-500/30 bg-dark-500/15 text-dark-400',
labelKey: 'admin.bulkActions.statuses.disabled',
},
};
const c = config[status || ''] || config.disabled;
return (
<span
className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium leading-tight',
c.class,
)}
>
{t(c.labelKey, status || '')}
</span>
);
}

View File

@@ -0,0 +1,27 @@
import type { BulkActionType } from '../../../api/adminBulkActions';
// ──────────────────────────────────────────────────────────────────
// Bulk-action target classification.
//
// Some actions operate on user-rows (assign promo group, add balance,
// delete user); others operate on individual subscription-rows
// (extend, cancel, change tariff, etc.). The set below is the source
// of truth shared between FloatingActionBar (renders two grouped
// menus when multi-tariff is on) and the parent page (gates the
// selection-count semantics and the active-paid count in delete).
// ──────────────────────────────────────────────────────────────────
export const SUBSCRIPTION_LEVEL_ACTIONS: Set<BulkActionType> = new Set([
'extend_subscription',
'add_days',
'cancel_subscription',
'activate_subscription',
'change_tariff',
'add_traffic',
'set_devices',
'delete_subscription',
]);
export function isSubscriptionLevelAction(action: BulkActionType): boolean {
return SUBSCRIPTION_LEVEL_ACTIONS.has(action);
}

View File

@@ -0,0 +1,36 @@
import { RISK_STYLES, formatGbPerDay, type RiskLevel } from './trafficUsageHelpers';
// ──────────────────────────────────────────────────────────────────
// RiskBadge — small dot + GB/d value + mini progress bar showing the
// ratio of observed GB/d to configured threshold. Used inside the
// AdminTrafficUsage column defs to colour rows by risk level.
// ──────────────────────────────────────────────────────────────────
export interface RiskBadgeProps {
level: RiskLevel;
ratio: number;
gbPerDay: number;
}
export function RiskBadge({ level, ratio, gbPerDay }: RiskBadgeProps) {
const style = RISK_STYLES[level];
const barWidth = Math.min(ratio * 100, 100);
return (
<div className="flex flex-col items-center gap-0.5">
<div className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${style.dot}`} />
<span className={`text-[11px] font-semibold tabular-nums ${style.text}`}>
{formatGbPerDay(gbPerDay)}
</span>
<span className={`text-[10px] ${style.text} opacity-60`}>GB/d</span>
</div>
<div className={`h-1 w-full max-w-[60px] rounded-full ${style.bg}`}>
<div
className={`h-full rounded-full ${style.bar} transition-all`}
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
// ──────────────────────────────────────────────────────────────────
// TrafficIcons — the inline SVG set used across AdminTrafficUsage
// (header controls, filter buttons, sort indicator, etc.). Page-local
// in spirit; co-located here so the parent page imports a single
// flat barrel rather than redefining 15 inline icons.
// ──────────────────────────────────────────────────────────────────
export const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
export const ChevronLeftIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
export const ChevronRightIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
export const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
export const DownloadIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
export const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => (
<svg
className="ml-1 inline h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : direction === 'desc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
)}
</svg>
);
export const FilterIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
export const ChevronDownIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
export const ServerIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
);
export const CalendarIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
);
export const XIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const StatusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
export const GlobeIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
export const ShieldIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
export const ServerSmallIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
);

View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDownIcon, GlobeIcon } from '../TrafficIcons';
import { getFlagEmoji } from '../trafficUsageHelpers';
export function CountryFilter({
available,
selected,
onChange,
}: {
available: { code: string; count: number }[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (available.length === 0) return null;
const allSelected = selected.size === 0;
const activeCount = selected.size;
const toggle = (code: string) => {
const next = new Set(selected);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
onChange(next);
};
const selectAll = () => onChange(new Set());
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
activeCount > 0
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`}
>
<GlobeIcon />
{activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount}
</span>
)}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 w-48 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl sm:left-0 sm:right-auto">
<button
onClick={selectAll}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
allSelected ? 'text-accent-400' : 'text-dark-300'
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{allSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span>
All
</button>
<div className="mx-2 border-t border-dark-700" />
<div className="max-h-48 overflow-y-auto">
{available.map(({ code, count }) => {
const checked = selected.has(code);
return (
<button
key={code}
onClick={() => toggle(code)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{checked && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
{getFlagEmoji(code)} {code.toUpperCase()}
<span className="ml-auto text-dark-500">{count}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDownIcon, ServerIcon } from '../TrafficIcons';
import { getFlagEmoji } from '../trafficUsageHelpers';
import type { TrafficNodeInfo } from '../../../../api/adminTraffic';
export function NodeFilter({
available,
selected,
onChange,
}: {
available: TrafficNodeInfo[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (available.length === 0) return null;
const allSelected = selected.size === 0;
const activeCount = selected.size;
const toggle = (uuid: string) => {
const next = new Set(selected);
if (next.has(uuid)) {
next.delete(uuid);
} else {
next.add(uuid);
}
onChange(next);
};
const selectAll = () => onChange(new Set());
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
activeCount > 0
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`}
>
<ServerIcon />
{t('admin.trafficUsage.nodes')}
{activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount}
</span>
)}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-64 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
<button
onClick={selectAll}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
allSelected ? 'text-accent-400' : 'text-dark-300'
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{allSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span>
{t('admin.trafficUsage.allNodes')}
</button>
<div className="mx-2 border-t border-dark-700" />
<div className="max-h-48 overflow-y-auto">
{available.map((node) => {
const checked = selected.has(node.node_uuid);
return (
<button
key={node.node_uuid}
onClick={() => toggle(node.node_uuid)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{checked && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
{getFlagEmoji(node.country_code)} {node.node_name}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next';
import { CalendarIcon, XIcon } from '../TrafficIcons';
// ──────────────────────────────────────────────────────────────────
// PeriodSelector — switches between fixed period tabs (1/3/7/14/30
// days) and a free custom-date-range mode. Parent owns all state;
// component is fully controlled.
//
// PERIODS is re-exported so the parent's prefetch effect iterates
// the same canonical list.
// ──────────────────────────────────────────────────────────────────
export const PERIODS = [1, 3, 7, 14, 30] as const;
export function PeriodSelector({
value,
onChange,
label,
dateMode,
customStart,
customEnd,
onToggleDateMode,
onCustomStartChange,
onCustomEndChange,
}: {
value: number;
onChange: (v: number) => void;
label: string;
dateMode: boolean;
customStart: string;
customEnd: string;
onToggleDateMode: () => void;
onCustomStartChange: (v: string) => void;
onCustomEndChange: (v: string) => void;
}) {
const { t } = useTranslation();
// Limit: last 31 days
const today = new Date().toISOString().split('T')[0];
const minDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
if (dateMode) {
return (
<div className="flex items-center gap-2">
<CalendarIcon />
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
<input
type="date"
value={customStart}
min={minDate}
max={customEnd || today}
onChange={(e) => onCustomStartChange(e.target.value)}
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
/>
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateTo')}</span>
<input
type="date"
value={customEnd}
min={customStart || minDate}
max={today}
onChange={(e) => onCustomEndChange(e.target.value)}
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
/>
<button
onClick={onToggleDateMode}
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
title={t('admin.trafficUsage.period')}
>
<XIcon />
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-400">{label}</span>
<div className="flex gap-1">
{PERIODS.map((p) => (
<button
key={p}
onClick={() => onChange(p)}
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
value === p
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
}`}
>
{p}
{t('admin.trafficUsage.days')}
</button>
))}
</div>
<button
onClick={onToggleDateMode}
className="rounded-lg border border-dark-700 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
title={t('admin.trafficUsage.customDates')}
>
<CalendarIcon />
</button>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDownIcon, StatusIcon } from '../TrafficIcons';
// Status colour pills shared with the StatusFilter dropdown.
export const STATUS_COLORS: Record<string, string> = {
active: 'bg-success-500',
trial: 'bg-warning-500',
expired: 'bg-error-500',
disabled: 'bg-dark-500',
};
export function StatusFilter({
available,
selected,
onChange,
}: {
available: string[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (available.length === 0) return null;
const allSelected = selected.size === 0;
const activeCount = selected.size;
const toggle = (status: string) => {
const next = new Set(selected);
if (next.has(status)) {
next.delete(status);
} else {
next.add(status);
}
onChange(next);
};
const selectAll = () => onChange(new Set());
const statusLabel = (s: string) => {
const key = `admin.trafficUsage.status${s.charAt(0).toUpperCase() + s.slice(1)}`;
return t(key, s);
};
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
activeCount > 0
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`}
>
<StatusIcon />
{t('admin.trafficUsage.status')}
{activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount}
</span>
)}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
<button
onClick={selectAll}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
allSelected ? 'text-accent-400' : 'text-dark-300'
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{allSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span>
{t('admin.trafficUsage.allStatuses')}
</button>
<div className="mx-2 border-t border-dark-700" />
<div className="max-h-48 overflow-y-auto">
{available.map((s) => {
const checked = selected.has(s);
return (
<button
key={s}
onClick={() => toggle(s)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{checked && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
<span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} />
{statusLabel(s)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDownIcon, FilterIcon } from '../TrafficIcons';
export function TariffFilter({
available,
selected,
onChange,
}: {
available: string[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (available.length === 0) return null;
const allSelected = selected.size === 0;
const activeCount = selected.size;
const toggle = (tariff: string) => {
const next = new Set(selected);
if (next.has(tariff)) {
next.delete(tariff);
} else {
next.add(tariff);
}
onChange(next);
};
const selectAll = () => onChange(new Set());
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
activeCount > 0
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`}
>
<FilterIcon />
{t('admin.trafficUsage.tariff')}
{activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount}
</span>
)}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
<button
onClick={selectAll}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
allSelected ? 'text-accent-400' : 'text-dark-300'
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{allSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span>
{t('admin.trafficUsage.allTariffs')}
</button>
<div className="mx-2 border-t border-dark-700" />
<div className="max-h-48 overflow-y-auto">
{available.map((tariff) => {
const checked = selected.has(tariff);
return (
<button
key={tariff}
onClick={() => toggle(tariff)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{checked && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
{tariff}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
import type { RowData } from '@tanstack/react-table';
import { getFlagEmoji as _sharedGetFlagEmoji } from '../../../utils/subscriptionHelpers';
import type { UserTrafficItem } from '../../../api/adminTraffic';
// ──────────────────────────────────────────────────────────────────
// TanStack Table module augmentation — shared by the AdminTrafficUsage
// column defs (sticky / align / bold per-column meta).
// ──────────────────────────────────────────────────────────────────
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
sticky?: boolean;
align?: 'left' | 'center';
bold?: boolean;
}
}
// ──────────────────────────────────────────────────────────────────
// Formatters
// ──────────────────────────────────────────────────────────────────
export const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Local wrapper over the shared helper so internal call sites keep
// the (string) signature.
export const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode);
export const formatCurrency = (kopeks: number): string => {
const rubles = kopeks / 100;
if (rubles === 0) return '0';
if (rubles < 10) return rubles.toFixed(2);
if (rubles < 1000) return Math.round(rubles).toString();
return `${(rubles / 1000).toFixed(1)}k`;
};
export const formatShortDate = (iso: string | null): string => {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`;
};
export const toBackendSortField = (columnId: string): string => {
if (columnId === 'user') return 'full_name';
return columnId;
};
// ──────────────────────────────────────────────────────────────────
// Risk assessment
// ──────────────────────────────────────────────────────────────────
export const bytesToGbPerDay = (bytes: number, days: number): number =>
days > 0 ? bytes / days / 1024 ** 3 : 0;
export const getRatio = (gbPerDay: number, threshold: number): number =>
threshold > 0 ? gbPerDay / threshold : 0;
export const getRowBgColor = (ratio: number): string | undefined => {
if (ratio <= 0) return undefined;
const clamped = Math.min(ratio, 1.5);
const hue = 120 - Math.min(clamped, 1) * 120;
const opacity = clamped <= 1 ? 0.06 + clamped * 0.07 : 0.13 + (clamped - 1) * 0.14;
return `hsla(${hue}, 70%, 45%, ${opacity})`;
};
export const getNodeTextColor = (ratio: number): string => {
const clamped = Math.min(Math.max(ratio, 0), 1.5);
let hue: number;
if (clamped <= 0.7) {
hue = 210 - (clamped / 0.7) * 180; // 210 (blue) → 30 (amber)
} else {
hue = Math.max(0, 30 - ((clamped - 0.7) / 0.8) * 30); // 30 (amber) → 0 (red)
}
const saturation = 70 + clamped * 15;
const lightness = 65 - clamped * 10;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
export const getRiskLevel = (ratio: number): RiskLevel => {
if (ratio < 0.5) return 'low';
if (ratio < 0.8) return 'medium';
if (ratio < 1.2) return 'high';
return 'critical';
};
export interface RiskResult {
ratio: number;
gbPerDay: number; // the dominant daily value (total or worst node)
totalRatio: number;
maxNodeRatio: number;
}
export const getCompositeRisk = (
row: UserTrafficItem,
totalThreshold: number,
nodeThreshold: number,
days: number,
): RiskResult => {
const dailyTotal = bytesToGbPerDay(row.total_bytes, days);
const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0;
let maxNodeR = 0;
let worstNodeGbPerDay = 0;
if (nodeThreshold > 0) {
for (const b of Object.values(row.node_traffic)) {
const daily = bytesToGbPerDay(b || 0, days);
const r = getRatio(daily, nodeThreshold);
if (r > maxNodeR) {
maxNodeR = r;
worstNodeGbPerDay = daily;
}
}
}
// The dominant metric determines what GB/d we show
const ratio = Math.max(totalR, maxNodeR);
const gbPerDay = totalR >= maxNodeR ? dailyTotal : worstNodeGbPerDay;
return { ratio, gbPerDay, totalRatio: totalR, maxNodeRatio: maxNodeR };
};
export const RISK_STYLES: Record<
RiskLevel,
{ dot: string; text: string; bar: string; bg: string }
> = {
low: {
dot: 'bg-success-400',
text: 'text-success-400',
bar: 'bg-success-400',
bg: 'bg-success-400/10',
},
medium: {
dot: 'bg-warning-400',
text: 'text-warning-400',
bar: 'bg-warning-400',
bg: 'bg-warning-400/10',
},
high: {
dot: 'bg-warning-400',
text: 'text-warning-400',
bar: 'bg-warning-400',
bg: 'bg-warning-400/10',
},
critical: {
dot: 'bg-error-400 animate-pulse',
text: 'text-error-400',
bar: 'bg-error-400',
bg: 'bg-error-400/10',
},
};
export const formatGbPerDay = (gbPerDay: number): string => {
if (gbPerDay < 0.01) return '<0.01';
if (gbPerDay < 10) return gbPerDay.toFixed(2);
if (gbPerDay < 100) return gbPerDay.toFixed(1);
return Math.round(gbPerDay).toString();
};

View File

@@ -0,0 +1,293 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNotify } from '../../../platform/hooks/useNotify';
import { useCurrency } from '../../../hooks/useCurrency';
import { adminUsersApi, type UserDetailResponse } from '../../../api/adminUsers';
import { promocodesApi } from '../../../api/promocodes';
import { promoOffersApi } from '../../../api/promoOffers';
import { createNumberInputHandler, toNumber } from '../../../utils/inputHelpers';
// ──────────────────────────────────────────────────────────────────
// Icons — local; balance is the only consumer.
// ──────────────────────────────────────────────────────────────────
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const MinusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
);
// ──────────────────────────────────────────────────────────────────
// Balance tab — current balance, add/subtract form, active promo
// offer summary, send-offer form, recent transactions list. State
// (form inputs + inline-confirm arm) is local; the parent only
// owns the user query and is told when to refresh.
// ──────────────────────────────────────────────────────────────────
export interface BalanceTabProps {
user: UserDetailResponse;
userId: number;
hasPermission: (perm: string) => boolean;
onUserRefresh: () => Promise<void> | void;
formatDate: (date: string | null) => string;
}
export function BalanceTab({
user,
userId,
hasPermission,
onUserRefresh,
formatDate,
}: BalanceTabProps) {
const { t } = useTranslation();
const notify = useNotify();
const { formatWithCurrency } = useCurrency();
const [balanceAmount, setBalanceAmount] = useState<number | ''>('');
const [balanceDescription, setBalanceDescription] = useState('');
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
const [actionLoading, setActionLoading] = useState(false);
const [offerSending, setOfferSending] = useState(false);
// Inline two-click confirm — local so other tabs aren't dimmed.
const [confirmingAction, setConfirmingAction] = useState<string | null>(null);
const handleInlineConfirm = (actionKey: string, executeFn: () => Promise<void>) => {
if (confirmingAction === actionKey) {
setConfirmingAction(null);
executeFn();
} else {
setConfirmingAction(actionKey);
setTimeout(() => {
setConfirmingAction((current) => (current === actionKey ? null : current));
}, 3000);
}
};
// ─── Mutations ──────────────────────────────────────────────────
const handleUpdateBalance = async (isAdd: boolean) => {
if (balanceAmount === '') return;
setActionLoading(true);
try {
const amount = Math.abs(toNumber(balanceAmount) * 100);
await adminUsersApi.updateBalance(userId, {
amount_kopeks: isAdd ? amount : -amount,
description:
balanceDescription ||
(isAdd
? t('admin.users.detail.balance.addByAdmin')
: t('admin.users.detail.balance.subtractByAdmin')),
});
await onUserRefresh();
setBalanceAmount('');
setBalanceDescription('');
} catch (error) {
console.error('Failed to update balance:', error);
} finally {
setActionLoading(false);
}
};
const handleDeactivateOffer = async () => {
setActionLoading(true);
try {
await promocodesApi.deactivateDiscount(userId);
notify.success(t('admin.users.detail.offerDeactivated'), t('common.success'));
await onUserRefresh();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleSendOffer = async () => {
if (offerDiscountPercent === '' || offerValidHours === '') return;
setOfferSending(true);
try {
await promoOffersApi.broadcastOffer({
user_id: userId,
notification_type: 'admin_personal',
discount_percent: toNumber(offerDiscountPercent),
valid_hours: toNumber(offerValidHours, 24),
effect_type: 'percent_discount',
send_notification: true,
});
notify.success(t('admin.users.detail.offerSent'), t('common.success'));
setOfferDiscountPercent('');
setOfferValidHours(24);
await onUserRefresh();
} catch {
notify.error(t('admin.users.detail.offerSendError'), t('common.error'));
} finally {
setOfferSending(false);
}
};
// ─── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* Current balance */}
<div className="rounded-xl border border-accent-500/30 bg-gradient-to-r from-accent-500/20 to-accent-700/20 p-4">
<div className="mb-1 text-sm text-dark-400">{t('admin.users.detail.balance.current')}</div>
<div className="text-3xl font-bold text-dark-100">
{formatWithCurrency(user.balance_rubles)}
</div>
</div>
{/* Add/subtract form */}
{hasPermission('users:balance') && (
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
<input
type="number"
value={balanceAmount}
onChange={createNumberInputHandler(setBalanceAmount)}
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
className="input"
/>
<input
type="text"
value={balanceDescription}
onChange={(e) => setBalanceDescription(e.target.value)}
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
className="input"
maxLength={500}
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateBalance(true)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
>
<PlusIcon /> {t('admin.users.detail.balance.add')}
</button>
<button
onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
</button>
</div>
</div>
)}
{/* Active promo offer */}
{user.promo_offer_discount_percent > 0 && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-accent-400">
{t('admin.users.detail.activePromoOffer')}
</span>
<button
onClick={() => handleInlineConfirm('deactivateOffer', handleDeactivateOffer)}
disabled={actionLoading}
className={`rounded-lg px-3 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'deactivateOffer'
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === 'deactivateOffer'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.deactivateOffer')}
</button>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-dark-100">
{user.promo_offer_discount_percent}%
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.discount')}</div>
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.promo_offer_discount_source || '-'}
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.source')}</div>
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.promo_offer_discount_expires_at
? formatDate(user.promo_offer_discount_expires_at)
: '-'}
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.expiresAt')}</div>
</div>
</div>
</div>
)}
{/* Send promo offer */}
{hasPermission('users:send_offer') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.sendOffer')}
</div>
<div className="space-y-3">
<input
type="number"
value={offerDiscountPercent}
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
placeholder={t('admin.users.detail.discountPercent')}
className="input"
min={1}
max={100}
/>
<input
type="number"
value={offerValidHours}
onChange={createNumberInputHandler(setOfferValidHours, 1)}
placeholder={t('admin.users.detail.validHours')}
className="input"
min={1}
max={8760}
/>
<button
onClick={handleSendOffer}
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
className="btn-primary w-full disabled:opacity-50"
>
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
</button>
</div>
</div>
)}
{/* Recent transactions */}
{user.recent_transactions.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 font-medium text-dark-200">
{t('admin.users.detail.balance.recentTransactions')}
</div>
<div className="max-h-48 space-y-2 overflow-y-auto">
{user.recent_transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between border-b border-dark-700 py-2 last:border-0"
>
<div>
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
</div>
<div className={tx.amount_kopeks >= 0 ? 'text-success-400' : 'text-error-400'}>
{tx.amount_kopeks >= 0 ? '+' : ''}
{formatWithCurrency(tx.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,301 @@
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../../../hooks/useCurrency';
import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers';
// ──────────────────────────────────────────────────────────────────
// Status badge
// ──────────────────────────────────────────────────────────────────
function GiftStatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const styles: Record<string, string> = {
pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
paid: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
delivered: 'bg-success-500/20 text-success-400 border-success-500/30',
pending_activation: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
failed: 'bg-error-500/20 text-error-400 border-error-500/30',
expired: 'bg-dark-600 text-dark-400 border-dark-500',
};
const fallback = 'bg-dark-600 text-dark-400 border-dark-500';
const label = t(`admin.users.detail.gifts.status.${status}`, { defaultValue: '' }) || status;
return (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${styles[status] || fallback}`}
>
{label}
</span>
);
}
// ──────────────────────────────────────────────────────────────────
// Single gift card
// ──────────────────────────────────────────────────────────────────
function GiftCard({
gift,
direction,
locale,
onNavigateToUser,
}: {
gift: AdminUserGiftItem;
direction: 'sent' | 'received';
locale: string;
onNavigateToUser: (userId: number) => void;
}) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const isSent = direction === 'sent';
const otherPartyLabel = isSent
? t('admin.users.detail.gifts.recipient')
: t('admin.users.detail.gifts.sender');
const otherPartyName = isSent
? gift.receiver_username
? `@${gift.receiver_username}`
: gift.gift_recipient_value || t('admin.users.detail.gifts.codeOnly')
: gift.buyer_username
? `@${gift.buyer_username}`
: gift.buyer_full_name || t('admin.users.detail.gifts.unknownUser');
const otherPartyId = isSent ? gift.receiver_user_id : gift.buyer_user_id;
const dateOpts: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
};
return (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 transition-colors hover:bg-dark-800/70">
{/* Header */}
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg ${isSent ? 'bg-accent-500/15' : 'bg-success-500/15'}`}
>
<svg
className={`h-4 w-4 ${isSent ? 'text-accent-400' : 'text-success-400'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div>
<div className="text-sm font-medium text-dark-100">{gift.tariff_name || '—'}</div>
<div className="text-xs text-dark-500">
{gift.period_days} {t('admin.users.detail.gifts.days')} · {gift.device_limit}{' '}
{t('admin.users.detail.gifts.devices')}
</div>
</div>
</div>
<GiftStatusBadge status={gift.status} />
</div>
{/* Details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div>
<span className="text-dark-500">{otherPartyLabel}:</span>{' '}
<span className="text-dark-300">{otherPartyName}</span>
{otherPartyId && (
<button
onClick={() => onNavigateToUser(otherPartyId)}
className="ml-1 text-accent-400 hover:text-accent-300"
>
#{otherPartyId}
</button>
)}
</div>
<div>
<span className="text-dark-500">{t('admin.users.detail.gifts.amount')}:</span>{' '}
<span className="text-dark-300">{formatWithCurrency(gift.amount_kopeks / 100)}</span>
</div>
<div>
<span className="text-dark-500">{t('admin.users.detail.gifts.paymentMethod')}:</span>{' '}
<span className="text-dark-300">{gift.payment_method || '—'}</span>
</div>
<div>
<span className="text-dark-500">{t('admin.users.detail.gifts.createdAt')}:</span>{' '}
<span className="text-dark-300">
{gift.created_at ? new Date(gift.created_at).toLocaleString(locale, dateOpts) : '—'}
</span>
</div>
{gift.paid_at && (
<div>
<span className="text-dark-500">{t('admin.users.detail.gifts.paidAt')}:</span>{' '}
<span className="text-dark-300">
{new Date(gift.paid_at).toLocaleString(locale, dateOpts)}
</span>
</div>
)}
{gift.delivered_at && (
<div>
<span className="text-dark-500">{t('admin.users.detail.gifts.deliveredAt')}:</span>{' '}
<span className="text-success-400">
{new Date(gift.delivered_at).toLocaleString(locale, dateOpts)}
</span>
</div>
)}
</div>
{/* Gift message */}
{gift.gift_message && (
<div className="mt-3 rounded-lg bg-dark-900/50 p-2.5 text-xs italic text-dark-400">
&ldquo;{gift.gift_message}&rdquo;
</div>
)}
{/* Token */}
<div className="mt-2 font-mono text-[10px] text-dark-600">GIFT-{gift.token}</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────
// Tab — gifts list with sent + received sections
// ──────────────────────────────────────────────────────────────────
export interface GiftsTabProps {
giftsLoading: boolean;
giftsData: AdminUserGiftsResponse | null;
locale: string;
onNavigateToUser: (userId: number) => void;
}
export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }: GiftsTabProps) {
const { t } = useTranslation();
if (giftsLoading) {
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 (!giftsData || (giftsData.sent.length === 0 && giftsData.received.length === 0)) {
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-16">
<svg
className="mb-3 h-12 w-12 text-dark-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
<p className="text-sm text-dark-500">{t('admin.users.detail.gifts.noGifts')}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Summary counters */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalSent')}
</div>
<div className="text-2xl font-bold text-accent-400">{giftsData.sent_total}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalReceived')}
</div>
<div className="text-2xl font-bold text-success-400">{giftsData.received_total}</div>
</div>
</div>
{/* Sent Gifts */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-dark-200">
<svg
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
{t('admin.users.detail.gifts.sentTitle')}
<span className="text-dark-500">({giftsData.sent_total})</span>
</h3>
{giftsData.sent.length === 0 ? (
<div className="rounded-xl bg-dark-800/30 py-6 text-center text-sm text-dark-500">
{t('admin.users.detail.gifts.noSent')}
</div>
) : (
<div className="space-y-2">
{giftsData.sent.map((gift) => (
<GiftCard
key={gift.id}
gift={gift}
direction="sent"
locale={locale}
onNavigateToUser={onNavigateToUser}
/>
))}
</div>
)}
</div>
{/* Received Gifts */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-dark-200">
<svg
className="h-4 w-4 text-success-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859"
/>
</svg>
{t('admin.users.detail.gifts.receivedTitle')}
<span className="text-dark-500">({giftsData.received_total})</span>
</h3>
{giftsData.received.length === 0 ? (
<div className="rounded-xl bg-dark-800/30 py-6 text-center text-sm text-dark-500">
{t('admin.users.detail.gifts.noReceived')}
</div>
) : (
<div className="space-y-2">
{giftsData.received.map((gift) => (
<GiftCard
key={gift.id}
gift={gift}
direction="received"
locale={locale}
onNavigateToUser={onNavigateToUser}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,540 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useCurrency } from '../../../hooks/useCurrency';
import { createNumberInputHandler } from '../../../utils/inputHelpers';
import type {
UserDetailResponse,
UserListItem,
UserPanelInfo,
UserSubscriptionInfo,
} from '../../../api/adminUsers';
import type { PromoGroup } from '../../../api/promocodes';
// ──────────────────────────────────────────────────────────────────
// Local status badge (parent has its own — duplicating here to keep
// this file self-contained for the tab's smallest unit of meaning).
// ──────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const styles: Record<string, string> = {
active: 'bg-success-500/20 text-success-400 border-success-500/30',
blocked: 'bg-error-500/20 text-error-400 border-error-500/30',
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
};
return (
<span
className={`rounded-full border px-2 py-0.5 text-xs font-medium ${styles[status] || styles.deleted}`}
>
{t(`admin.users.status.${status}`, status)}
</span>
);
}
// ──────────────────────────────────────────────────────────────────
// Info tab — user metadata + VPN connection + promo group + referral
// + restrictions + 4 destructive actions (reset/disable/delete).
//
// The tab is a "view facade" — it reads parent state (panelInfo,
// promoGroups, etc. live up there because subscription tab also
// consumes them) and delegates every mutation to a parent handler.
// Local UI state is parent-owned for the same reason: the parent
// already maintains the inline-confirm armer for actions shared
// with the subscription tab.
// ──────────────────────────────────────────────────────────────────
export interface InfoTabProps {
user: UserDetailResponse;
hasPermission: (perm: string) => boolean;
formatDate: (date: string | null) => string;
locale: string;
// Subscription / panel info (also used by Subscription tab in parent)
panelInfo: UserPanelInfo | null;
panelInfoLoading: boolean;
userSubscriptions: UserSubscriptionInfo[];
activeSubscriptionId: number | null;
onActiveSubscriptionChange: (id: number) => void;
// Promo-group editor
promoGroups: PromoGroup[];
editingPromoGroup: boolean;
onToggleEditingPromoGroup: () => void;
onChangePromoGroup: (groupId: number | null) => void;
// Referral commission editor
editingReferralCommission: boolean;
referralCommissionValue: number | '';
onSetReferralCommissionValue: (value: number | '') => void;
onToggleEditingReferralCommission: () => void;
onUpdateReferralCommission: () => void;
// Referrals mini-list
referrals: UserListItem[];
referralsLoading: boolean;
// Status block/unblock
actionLoading: boolean;
onBlockUser: () => void;
onUnblockUser: () => void;
// Destructive actions + inline-confirm armer
confirmingAction: string | null;
onInlineConfirm: (actionKey: string, executeFn: () => Promise<void>) => void;
onResetTrial: () => Promise<void>;
onResetSubscription: () => Promise<void>;
onDisableUser: () => Promise<void>;
onFullDeleteUser: () => Promise<void>;
}
export function InfoTab(props: InfoTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const navigate = useNavigate();
const {
user,
hasPermission,
formatDate,
locale,
panelInfo,
panelInfoLoading,
userSubscriptions,
activeSubscriptionId,
onActiveSubscriptionChange,
promoGroups,
editingPromoGroup,
onToggleEditingPromoGroup,
onChangePromoGroup,
editingReferralCommission,
referralCommissionValue,
onSetReferralCommissionValue,
onToggleEditingReferralCommission,
onUpdateReferralCommission,
referrals,
referralsLoading,
actionLoading,
onBlockUser,
onUnblockUser,
confirmingAction,
onInlineConfirm,
onResetTrial,
onResetSubscription,
onDisableUser,
onFullDeleteUser,
} = props;
return (
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 p-3">
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
<div className="flex items-center gap-2">
<StatusBadge status={user.status} />
{user.status === 'active' ? (
<button
onClick={onBlockUser}
disabled={actionLoading}
className="rounded-lg bg-error-500/20 px-3 py-1 text-xs text-error-400 transition-colors hover:bg-error-500/30"
>
{t('admin.users.actions.block')}
</button>
) : user.status === 'blocked' ? (
<button
onClick={onUnblockUser}
disabled={actionLoading}
className="rounded-lg bg-success-500/20 px-3 py-1 text-xs text-success-400 transition-colors hover:bg-success-500/30"
>
{t('admin.users.actions.unblock')}
</button>
) : null}
</div>
</div>
{/* Details grid */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">Email</div>
<div className="text-dark-100">{user.email || '-'}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.language')}</div>
<div className="text-dark-100">{user.language}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.registration')}</div>
<div className="text-dark-100">{formatDate(user.created_at)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.botActivity')}</div>
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.cabinetLastLogin')}
</div>
<div className="text-dark-100">{formatDate(user.cabinet_last_login)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.totalSpent')}</div>
<div className="text-dark-100">{formatWithCurrency(user.total_spent_kopeks / 100)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.purchases')}</div>
<div className="text-dark-100">{user.purchase_count}</div>
</div>
</div>
{/* VPN Connection Info */}
{(panelInfo || userSubscriptions.length > 0) && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.vpnConnection')}
</span>
{userSubscriptions.length > 1 && (
<select
value={activeSubscriptionId ?? ''}
onChange={(e) => onActiveSubscriptionChange(Number(e.target.value))}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs text-dark-200"
>
{userSubscriptions.map((s) => (
<option key={s.id} value={s.id}>
{s.tariff_name || `#${s.id}`} {s.status}
</option>
))}
</select>
)}
</div>
{panelInfoLoading && !panelInfo?.found && (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{panelInfo?.found && (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastConnection')}
</div>
<div className="flex items-center gap-2">
{panelInfo.online_at &&
(() => {
const onlineDate = new Date(panelInfo.online_at);
const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000;
return (
<>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${isRecent ? 'bg-success-400 shadow-[0_0_6px_rgba(74,222,128,0.5)]' : 'bg-dark-500'}`}
title={isRecent ? t('admin.users.detail.online') : ''}
/>
<span
className={`text-sm ${isRecent ? 'font-medium text-success-400' : 'text-dark-100'}`}
>
{isRecent
? t('admin.users.detail.online')
: formatDate(panelInfo.online_at)}
</span>
</>
);
})()}
{!panelInfo.online_at && <span className="text-sm text-dark-100">-</span>}
</div>
</div>
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.firstConnection')}
</div>
<div className="text-sm text-dark-100">
{panelInfo.first_connected_at
? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '-'}
</div>
</div>
{panelInfo.last_connected_node_name && (
<div className="col-span-2 rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastNode')}
</div>
<div className="flex items-center gap-2 text-sm text-dark-100">
<svg
className="h-4 w-4 shrink-0 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
{panelInfo.last_connected_node_name}
</div>
</div>
)}
</div>
)}
{!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.noVpnData')}
</div>
)}
</div>
)}
{/* Campaign */}
{user.campaign_name && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.campaign')}</div>
<div className="text-sm font-medium text-accent-400">{user.campaign_name}</div>
</div>
)}
{/* Promo Group */}
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
{hasPermission('users:promo_group') && (
<button
onClick={onToggleEditingPromoGroup}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingPromoGroup ? t('common.cancel') : t('admin.users.detail.changePromoGroup')}
</button>
)}
</div>
{editingPromoGroup ? (
<div className="mt-2 space-y-2">
<select
value={user.promo_group?.id ?? ''}
onChange={(e) => {
const val = e.target.value;
onChangePromoGroup(val ? parseInt(val, 10) : null);
}}
disabled={actionLoading}
className="input text-sm"
>
<option value="">{t('admin.users.detail.selectPromoGroup')}</option>
{promoGroups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{user.promo_group && (
<button
onClick={() => onChangePromoGroup(null)}
disabled={actionLoading}
className="w-full rounded-lg bg-dark-700 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600"
>
{t('admin.users.detail.removePromoGroup')}
</button>
)}
</div>
) : (
<div className="text-sm font-medium text-dark-100">
{user.promo_group?.name || (
<span className="text-dark-500">{t('admin.users.detail.noPromoGroup')}</span>
)}
</div>
)}
</div>
{/* Referral */}
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.referral.title')}
</span>
{hasPermission('users:referral') && (
<button
onClick={onToggleEditingReferralCommission}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingReferralCommission ? t('common.cancel') : t('common.edit')}
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-dark-100">{user.referral.referrals_count}</div>
<div className="text-xs text-dark-500">
{t('admin.users.detail.referral.referrals')}
</div>
</div>
<div>
<div className="text-lg font-bold text-dark-100">
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.referral.earned')}</div>
</div>
<div>
{editingReferralCommission ? (
<div className="space-y-1">
<input
type="number"
value={referralCommissionValue}
onChange={createNumberInputHandler(onSetReferralCommissionValue, 0)}
placeholder="0-100"
className="input w-full text-center text-sm"
min={0}
max={100}
disabled={actionLoading}
/>
<button
onClick={onUpdateReferralCommission}
disabled={actionLoading}
className="w-full rounded-lg bg-accent-500 px-2 py-1 text-xs text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{actionLoading ? t('common.loading') : t('common.save')}
</button>
</div>
) : (
<>
<div className="text-lg font-bold text-dark-100">
{user.referral.commission_percent != null
? `${user.referral.commission_percent}%`
: t('admin.users.detail.referral.default')}
</div>
<div className="text-xs text-dark-500">
{t('admin.users.detail.referral.commission')}
</div>
</>
)}
</div>
</div>
</div>
{/* Referrals list */}
{user.referral.referrals_count > 0 && (
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 text-sm font-medium text-dark-200">
{t('admin.users.detail.referralsList')}
</div>
{referralsLoading ? (
<div className="flex justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : referrals.length === 0 ? (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.noReferrals')}
</div>
) : (
<div className="max-h-48 space-y-2 overflow-y-auto">
{referrals.map((ref) => (
<button
key={ref.id}
onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex w-full items-center justify-between rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-dark-600 text-xs font-bold text-dark-300">
{ref.first_name?.[0] || ref.username?.[0] || '?'}
</div>
<div>
<div className="text-sm text-dark-100">{ref.full_name}</div>
<div className="text-xs text-dark-500">{formatDate(ref.created_at)}</div>
</div>
</div>
<div className="text-xs text-dark-400">
{formatWithCurrency(ref.total_spent_kopeks / 100)}
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Restrictions */}
{(user.restriction_topup || user.restriction_subscription) && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
<div className="mb-2 text-sm font-medium text-error-400">
{t('admin.users.detail.restrictions.title')}
</div>
{user.restriction_topup && (
<div className="text-xs text-error-300">
{t('admin.users.detail.restrictions.topup')}
</div>
)}
{user.restriction_subscription && (
<div className="text-xs text-error-300">
{t('admin.users.detail.restrictions.subscription')}
</div>
)}
{user.restriction_reason && (
<div className="mt-1 text-xs text-dark-400">
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
</div>
)}
</div>
)}
{/* Actions */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.actions.title')}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'resetTrial'
? 'bg-accent-500 text-white'
: 'bg-accent-500/15 text-accent-400 hover:bg-accent-500/25'
}`}
>
{confirmingAction === 'resetTrial'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.userActions.resetTrial')}
</button>
<button
onClick={() => onInlineConfirm('resetSubscription', onResetSubscription)}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'resetSubscription'
? 'bg-warning-500 text-white'
: 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
}`}
>
{confirmingAction === 'resetSubscription'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.userActions.resetSubscription')}
</button>
<button
onClick={() => onInlineConfirm('disable', onDisableUser)}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'disable'
? 'bg-dark-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
{confirmingAction === 'disable'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.userActions.disable')}
</button>
<button
onClick={() => onInlineConfirm('fullDelete', onFullDeleteUser)}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'fullDelete'
? 'bg-rose-500 text-white'
: 'bg-rose-500/15 text-rose-400 hover:bg-rose-500/25'
}`}
>
{confirmingAction === 'fullDelete'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.userActions.delete')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,501 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { useCurrency } from '../../../hooks/useCurrency';
import { useNotify } from '../../../platform/hooks/useNotify';
import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers';
// ──────────────────────────────────────────────────────────────────
// Referrals tab — top-of-graph referrer + stats + referrals list,
// plus inline search/assign/remove flows. All state stays local;
// the parent only owns the user query and tells us when it refreshes.
// ──────────────────────────────────────────────────────────────────
export interface ReferralsTabProps {
user: UserDetailResponse;
userId: number;
onUserRefresh: () => Promise<void> | void;
}
export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const navigate = useNavigate();
const notify = useNotify();
// Referrals list — owned here, not in the parent.
const referralsListQuery = useQuery({
queryKey: ['admin-user-referrals-list', userId] as const,
queryFn: () => adminUsersApi.getReferrals(userId, 0, 100),
enabled: !!userId,
});
const referralsList = referralsListQuery.data?.users ?? [];
const referralsTotal = referralsListQuery.data?.total ?? 0;
const referralsListLoading = referralsListQuery.isFetching;
// Action gating — local so other tabs' buttons aren't dimmed during a
// referral mutation here.
const [actionLoading, setActionLoading] = useState(false);
// Inline referrer search dropdown
const [showReferrerSearch, setShowReferrerSearch] = useState(false);
const [referrerSearchQuery, setReferrerSearchQuery] = useState('');
const [referrerSearchResults, setReferrerSearchResults] = useState<UserListItem[]>([]);
const [referrerSearchLoading, setReferrerSearchLoading] = useState(false);
const referrerSearchRef = useRef<HTMLDivElement>(null);
// Inline add-referral search dropdown
const [showAddReferral, setShowAddReferral] = useState(false);
const [addReferralSearchQuery, setAddReferralSearchQuery] = useState('');
const [addReferralSearchResults, setAddReferralSearchResults] = useState<UserListItem[]>([]);
const [addReferralSearchLoading, setAddReferralSearchLoading] = useState(false);
const addReferralSearchRef = useRef<HTMLDivElement>(null);
// Debounced search for referrer
useEffect(() => {
if (referrerSearchQuery.length < 2 || !showReferrerSearch) {
setReferrerSearchResults([]);
setReferrerSearchLoading(false);
return;
}
setReferrerSearchLoading(true);
setReferrerSearchResults([]);
let cancelled = false;
const timer = setTimeout(async () => {
try {
const data = await adminUsersApi.getUsers({ search: referrerSearchQuery, limit: 10 });
if (!cancelled) {
setReferrerSearchResults(data.users || []);
setReferrerSearchLoading(false);
}
} catch {
if (!cancelled) {
setReferrerSearchResults([]);
setReferrerSearchLoading(false);
}
}
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [referrerSearchQuery, showReferrerSearch]);
// Close referrer dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (referrerSearchRef.current && !referrerSearchRef.current.contains(e.target as Node)) {
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
}
};
if (showReferrerSearch) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showReferrerSearch]);
// Debounced search for adding referral
useEffect(() => {
if (addReferralSearchQuery.length < 2 || !showAddReferral) {
setAddReferralSearchResults([]);
setAddReferralSearchLoading(false);
return;
}
setAddReferralSearchLoading(true);
setAddReferralSearchResults([]);
let cancelled = false;
const timer = setTimeout(async () => {
try {
const data = await adminUsersApi.getUsers({ search: addReferralSearchQuery, limit: 10 });
if (!cancelled) {
setAddReferralSearchResults(data.users || []);
setAddReferralSearchLoading(false);
}
} catch {
if (!cancelled) {
setAddReferralSearchResults([]);
setAddReferralSearchLoading(false);
}
}
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [addReferralSearchQuery, showAddReferral]);
// Close add-referral dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
addReferralSearchRef.current &&
!addReferralSearchRef.current.contains(e.target as Node)
) {
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
}
};
if (showAddReferral) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showAddReferral]);
// ─── Mutation handlers ───────────────────────────────────────────
const handleAssignReferrer = async (referrerId: number) => {
setActionLoading(true);
try {
await adminUsersApi.assignReferrer(userId, referrerId);
await onUserRefresh();
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
notify.success(t('admin.users.detail.referrals.referrerAssigned'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleRemoveReferrer = async () => {
setActionLoading(true);
try {
await adminUsersApi.removeReferrer(userId);
await onUserRefresh();
notify.success(t('admin.users.detail.referrals.referrerRemoved'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleRemoveReferral = async (referralUserId: number) => {
setActionLoading(true);
try {
await adminUsersApi.removeReferral(userId, referralUserId);
await referralsListQuery.refetch();
await onUserRefresh();
notify.success(t('admin.users.detail.referrals.referralRemoved'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleAddReferral = async (targetUserId: number) => {
setActionLoading(true);
try {
await adminUsersApi.assignReferrer(targetUserId, userId);
await referralsListQuery.refetch();
await onUserRefresh();
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
notify.success(t('admin.users.detail.referrals.referralAdded'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
// ─── Render ──────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Section 1: Who referred this user */}
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/40 p-5">
<h3 className="mb-4 text-base font-semibold text-dark-100">
{t('admin.users.detail.referrals.referredBy')}
</h3>
{user.referral.referred_by_id ? (
<div className="flex items-center justify-between gap-3">
<button
onClick={() => navigate(`/admin/users/${user.referral.referred_by_id}`)}
className="flex items-center gap-3 rounded-xl bg-dark-700/30 px-4 py-3 transition-colors hover:bg-dark-700/50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-500/20 text-sm font-bold text-accent-400">
{(user.referral.referred_by_username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.referral.referred_by_username || `ID: ${user.referral.referred_by_id}`}
</div>
<div className="text-xs text-dark-500">ID: {user.referral.referred_by_id}</div>
</div>
</button>
<button
onClick={handleRemoveReferrer}
disabled={actionLoading}
className="rounded-lg border border-error-500/30 bg-error-500/10 px-3 py-2 text-sm text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
{t('admin.users.detail.referrals.removeReferrer')}
</button>
</div>
) : (
<div>
{showReferrerSearch ? (
<div ref={referrerSearchRef} className="relative">
<div className="flex gap-2">
<input
type="text"
value={referrerSearchQuery}
onChange={(e) => setReferrerSearchQuery(e.target.value)}
placeholder={t('admin.users.detail.referrals.searchPlaceholder')}
className="flex-1 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
autoFocus
/>
<button
onClick={() => {
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
}}
className="rounded-lg bg-dark-700 px-3 py-2.5 text-sm text-dark-400 hover:bg-dark-600"
>
{t('common.cancel')}
</button>
</div>
{referrerSearchQuery.length >= 2 && referrerSearchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{referrerSearchResults
.filter((u) => u.id !== userId)
.map((u) => (
<button
key={u.id}
onClick={() => handleAssignReferrer(u.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-dark-700/50"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600/50 text-xs font-bold text-dark-300">
{(u.full_name || u.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm text-dark-100">
{u.full_name || u.username || `ID: ${u.id}`}
</div>
<div className="text-xs text-dark-500">
{u.telegram_id ? `TG: ${u.telegram_id}` : `ID: ${u.id}`}
</div>
</div>
</button>
))}
{referrerSearchResults.filter((u) => u.id !== userId).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-dark-500">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{referrerSearchQuery.length >= 2 &&
!referrerSearchLoading &&
referrerSearchResults.length === 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700 bg-dark-800 py-4 text-center text-sm text-dark-500 shadow-xl">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-dark-500">
{t('admin.users.detail.referrals.noReferrer')}
</span>
<button
onClick={() => setShowReferrerSearch(true)}
className="rounded-lg bg-accent-500/15 px-3 py-2 text-sm text-accent-400 transition-colors hover:bg-accent-500/25"
>
{t('admin.users.detail.referrals.assignReferrer')}
</button>
</div>
)}
</div>
)}
</div>
{/* Section 2: Referral stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalReferrals')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.referrals_count}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalEarnings')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.commission')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.commission_percent != null
? `${user.referral.commission_percent}%`
: t('admin.users.detail.referrals.default')}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.referralCode')}
</div>
<div className="mt-1 truncate font-mono text-sm text-dark-100">
{user.referral.referral_code}
</div>
</div>
</div>
{/* Section 3: Referrals list */}
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/40 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.users.detail.referrals.referralsList')} ({referralsTotal})
</h3>
{!showAddReferral && (
<button
onClick={() => setShowAddReferral(true)}
className="rounded-lg bg-accent-500/15 px-3 py-2 text-sm text-accent-400 transition-colors hover:bg-accent-500/25"
>
{t('admin.users.detail.referrals.addReferral')}
</button>
)}
</div>
{/* Add referral search */}
{showAddReferral && (
<div ref={addReferralSearchRef} className="relative mb-4">
<div className="flex gap-2">
<input
type="text"
value={addReferralSearchQuery}
onChange={(e) => setAddReferralSearchQuery(e.target.value)}
placeholder={t('admin.users.detail.referrals.searchPlaceholder')}
className="flex-1 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
autoFocus
/>
<button
onClick={() => {
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
}}
className="rounded-lg bg-dark-700 px-3 py-2.5 text-sm text-dark-400 hover:bg-dark-600"
>
{t('common.cancel')}
</button>
</div>
{addReferralSearchQuery.length >= 2 && addReferralSearchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{addReferralSearchResults
.filter((u) => u.id !== userId && !referralsList.some((r) => r.id === u.id))
.map((u) => (
<button
key={u.id}
onClick={() => handleAddReferral(u.id)}
disabled={actionLoading}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-dark-700/50 disabled:opacity-50"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600/50 text-xs font-bold text-dark-300">
{(u.full_name || u.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm text-dark-100">
{u.full_name || u.username || `ID: ${u.id}`}
</div>
<div className="text-xs text-dark-500">
{u.telegram_id ? `TG: ${u.telegram_id}` : `ID: ${u.id}`}
</div>
</div>
</button>
))}
{addReferralSearchResults.filter(
(u) => u.id !== userId && !referralsList.some((r) => r.id === u.id),
).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-dark-500">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{addReferralSearchQuery.length >= 2 &&
!addReferralSearchLoading &&
addReferralSearchResults.length === 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700 bg-dark-800 py-4 text-center text-sm text-dark-500 shadow-xl">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{referralsListLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : referralsList.length === 0 ? (
<div className="py-8 text-center text-dark-500">
{t('admin.users.detail.referrals.noReferrals')}
</div>
) : (
<div className="space-y-2">
{referralsList.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between rounded-xl bg-dark-700/20 px-4 py-3"
>
<button
onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex items-center gap-3 text-left"
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-dark-600/50 text-sm font-bold text-dark-300">
{(ref.full_name || ref.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{ref.full_name || ref.username || `ID: ${ref.id}`}
</div>
<div className="text-xs text-dark-500">
{ref.telegram_id ? `TG: ${ref.telegram_id}` : `ID: ${ref.id}`}
</div>
</div>
</button>
<button
onClick={() => handleRemoveReferral(ref.id)}
disabled={actionLoading}
className="rounded-lg p-2 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50"
title={t('admin.users.detail.referrals.removeReferral')}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
import { useTranslation } from 'react-i18next';
import type {
UserDetailResponse,
UserSubscriptionInfo,
PanelSyncStatusResponse,
} from '../../../api/adminUsers';
// ──────────────────────────────────────────────────────────────────
// Icons (sync-tab-local — not used outside this view)
// ──────────────────────────────────────────────────────────────────
const ArrowDownIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
);
const ArrowUpIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
);
// ──────────────────────────────────────────────────────────────────
// Sync tab — compares bot DB vs panel data, offers a 2-way push
// ──────────────────────────────────────────────────────────────────
export interface SyncTabProps {
user: UserDetailResponse;
syncStatus: PanelSyncStatusResponse | null;
userSubscriptions: UserSubscriptionInfo[];
activeSubscriptionId: number | null;
onActiveSubscriptionChange: (id: number | null) => void;
actionLoading: boolean;
onSyncFromPanel: () => void;
onSyncToPanel: () => void;
locale: string;
}
export function SyncTab({
user,
syncStatus,
userSubscriptions,
activeSubscriptionId,
onActiveSubscriptionChange,
actionLoading,
onSyncFromPanel,
onSyncToPanel,
locale,
}: SyncTabProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Subscription selector for multi-tariff */}
{userSubscriptions.length > 1 && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-2 text-sm text-dark-400">
{t('admin.users.detail.sync.selectSubscription')}
</div>
<select
value={activeSubscriptionId || ''}
onChange={(e) => onActiveSubscriptionChange(Number(e.target.value) || null)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100"
>
{userSubscriptions.map((sub) => (
<option key={sub.id} value={sub.id}>
{sub.tariff_name || `#${sub.id}`} {sub.status}
</option>
))}
</select>
</div>
)}
{/* Sync status */}
{syncStatus && (
<div
className={`rounded-xl border p-4 ${syncStatus.has_differences ? 'border-warning-500/30 bg-warning-500/10' : 'border-success-500/30 bg-success-500/10'}`}
>
<div className="mb-3 flex items-center gap-2">
{syncStatus.has_differences ? (
<span className="font-medium text-warning-400">
{t('admin.users.detail.sync.hasDifferences')}
</span>
) : (
<span className="font-medium text-success-400">
{t('admin.users.detail.sync.synced')}
</span>
)}
</div>
{syncStatus.differences.length > 0 && (
<div className="mb-3 space-y-1">
{syncStatus.differences.map((diff, i) => (
<div key={i} className="text-xs text-dark-300">
{diff}
</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="mb-2 text-xs text-dark-500">{t('admin.users.detail.sync.bot')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.statusLabel')}:</span>
<span className="text-dark-200">{syncStatus.bot_subscription_status || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
<span className="text-dark-200">
{syncStatus.bot_subscription_end_date
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(locale)
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.traffic')}:</span>
<span className="text-dark-200">
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.devices')}:</span>
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.squads')}:</span>
<span className="text-dark-200">{syncStatus.bot_squads?.length || 0}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 text-xs text-dark-500">{t('admin.users.detail.sync.panel')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.statusLabel')}:</span>
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
<span className="text-dark-200">
{syncStatus.panel_expire_at
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.traffic')}:</span>
<span className="text-dark-200">
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.devices')}:</span>
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">{t('admin.users.detail.sync.squads')}:</span>
<span className="text-dark-200">{syncStatus.panel_squads?.length || 0}</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* UUID info */}
<div className="rounded-xl bg-dark-800/50 p-4">
{syncStatus?.subscription_tariff_name && (
<div className="mb-2 text-xs text-dark-500">{syncStatus.subscription_tariff_name}</div>
)}
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
<div className="break-all font-mono text-sm text-dark-100">
{syncStatus?.remnawave_uuid ||
user.remnawave_uuid ||
t('admin.users.detail.sync.notLinked')}
</div>
</div>
{/* Sync buttons */}
<div className="grid grid-cols-2 gap-3">
<button
onClick={onSyncFromPanel}
disabled={actionLoading}
className="flex flex-col items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4 text-accent-400 transition-all hover:bg-accent-500/20 disabled:opacity-50"
>
<ArrowDownIcon className={`h-6 w-6 ${actionLoading ? 'animate-pulse' : ''}`} />
<span className="text-center text-xs font-medium">
{t('admin.users.detail.sync.fromPanel')}
</span>
</button>
<button
onClick={onSyncToPanel}
disabled={actionLoading}
className="flex flex-col items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4 text-accent-400 transition-all hover:bg-accent-500/20 disabled:opacity-50"
>
<ArrowUpIcon className={`h-6 w-6 ${actionLoading ? 'animate-pulse' : ''}`} />
<span className="text-center text-xs font-medium">
{t('admin.users.detail.sync.toPanel')}
</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,394 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../../../api/admin';
import { MessageMediaGrid } from '../../tickets/MessageMediaGrid';
import { linkifyText } from '../../../utils/linkify';
// ──────────────────────────────────────────────────────────────────
// Tickets tab — list view + chat view (selected ticket replaces list).
// Owns its own query, selection state, reply form state, and the
// auto-scroll-to-bottom ref. Parent only knows the userId.
// ──────────────────────────────────────────────────────────────────
export interface TicketsTabProps {
userId: number;
formatDate: (date: string | null) => string;
}
const STATUS_VALUES = ['open', 'pending', 'answered', 'closed'] as const;
type TicketStatus = (typeof STATUS_VALUES)[number];
export function TicketsTab({ userId, formatDate }: TicketsTabProps) {
// List query
const ticketsQuery = useQuery({
queryKey: ['admin-user-tickets', userId] as const,
queryFn: () => adminApi.getTickets({ user_id: userId, per_page: 50 }),
enabled: !!userId,
});
const tickets = ticketsQuery.data?.items ?? [];
const ticketsTotal = ticketsQuery.data?.total ?? 0;
const ticketsLoading = ticketsQuery.isFetching;
// Selected ticket (chat view)
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null);
const [selectedTicket, setSelectedTicket] = useState<AdminTicketDetail | null>(null);
const [ticketDetailLoading, setTicketDetailLoading] = useState(false);
// Reply form
const [replyText, setReplyText] = useState('');
const [replySending, setReplySending] = useState(false);
// Status-change gate
const [actionLoading, setActionLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
// ─── Detail loader / refresh chain ──────────────────────────────
const loadTicketDetail = async (ticketId: number) => {
try {
setTicketDetailLoading(true);
const data = await adminApi.getTicket(ticketId);
setSelectedTicket(data);
} catch (error) {
console.error('Failed to load ticket detail:', error);
} finally {
setTicketDetailLoading(false);
}
};
useEffect(() => {
if (selectedTicketId) {
loadTicketDetail(selectedTicketId);
}
}, [selectedTicketId]);
// Auto-scroll messages list to the latest reply
useEffect(() => {
if (selectedTicket && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [selectedTicket]);
// ─── Mutations ──────────────────────────────────────────────────
const handleTicketReply = async () => {
if (!selectedTicketId || !replyText.trim()) return;
setReplySending(true);
try {
await adminApi.replyToTicket(selectedTicketId, replyText);
setReplyText('');
await loadTicketDetail(selectedTicketId);
await ticketsQuery.refetch();
} catch (error) {
console.error('Failed to reply:', error);
} finally {
setReplySending(false);
}
};
const handleTicketStatusChange = async (newStatus: TicketStatus) => {
if (!selectedTicketId) return;
setActionLoading(true);
try {
await adminApi.updateTicketStatus(selectedTicketId, newStatus);
await loadTicketDetail(selectedTicketId);
await ticketsQuery.refetch();
} catch (error) {
console.error('Failed to update ticket status:', error);
} finally {
setActionLoading(false);
}
};
// ─── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-4">
{selectedTicketId ? (
/* Ticket Chat View */
ticketDetailLoading ? (
<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>
) : selectedTicket ? (
<ChatView
selectedTicket={selectedTicket}
actionLoading={actionLoading}
replyText={replyText}
replySending={replySending}
messagesEndRef={messagesEndRef}
onBack={() => {
setSelectedTicketId(null);
setSelectedTicket(null);
}}
onStatusChange={handleTicketStatusChange}
onReplyTextChange={setReplyText}
onReply={handleTicketReply}
formatDate={formatDate}
/>
) : null
) : ticketsLoading ? (
<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>
) : tickets.length === 0 ? (
<EmptyState />
) : (
<TicketsList
tickets={tickets}
ticketsTotal={ticketsTotal}
onOpenTicket={setSelectedTicketId}
formatDate={formatDate}
/>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────────────
// Sub-views (kept private to this file for now)
// ──────────────────────────────────────────────────────────────────
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12">
<svg
className="mb-3 h-12 w-12 text-dark-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<p className="text-dark-400">{t('admin.users.detail.noTickets')}</p>
</div>
);
}
function TicketsList({
tickets,
ticketsTotal,
onOpenTicket,
formatDate,
}: {
tickets: AdminTicket[];
ticketsTotal: number;
onOpenTicket: (id: number) => void;
formatDate: (date: string | null) => string;
}) {
const { t } = useTranslation();
const statusStyles: Record<string, string> = {
open: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
answered: 'bg-success-500/20 text-success-400 border-success-500/30',
closed: 'bg-dark-600 text-dark-400 border-dark-500',
};
return (
<>
<div className="text-sm text-dark-400">
{ticketsTotal} {t('admin.users.detail.ticketsCount')}
</div>
<div className="space-y-2">
{tickets.map((ticket) => (
<button
key={ticket.id}
onClick={() => onOpenTicket(ticket.id)}
className="w-full rounded-xl bg-dark-800/50 p-4 text-left transition-colors hover:bg-dark-700/50"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium text-dark-100">
#{ticket.id} {ticket.title}
</span>
<span
className={`rounded-full border px-2 py-0.5 text-xs ${statusStyles[ticket.status] || statusStyles.closed}`}
>
{ticket.status}
</span>
</div>
<div className="flex items-center justify-between text-xs text-dark-500">
<span>{formatDate(ticket.created_at)}</span>
<span>
{ticket.messages_count} {t('admin.users.detail.messagesCount')}
</span>
</div>
{ticket.last_message && (
<div className="mt-2 truncate text-sm text-dark-400">
{ticket.last_message.is_from_admin ? '> ' : ''}
{ticket.last_message.message_text}
</div>
)}
</button>
))}
</div>
</>
);
}
function ChatView({
selectedTicket,
actionLoading,
replyText,
replySending,
messagesEndRef,
onBack,
onStatusChange,
onReplyTextChange,
onReply,
formatDate,
}: {
selectedTicket: AdminTicketDetail;
actionLoading: boolean;
replyText: string;
replySending: boolean;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onBack: () => void;
onStatusChange: (s: TicketStatus) => void;
onReplyTextChange: (text: string) => void;
onReply: () => void;
formatDate: (date: string | null) => string;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Chat header */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
aria-label={t('common.back', 'Back')}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-800 transition-colors hover:bg-dark-700 sm:h-8 sm:w-8"
>
<svg
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-dark-100">
#{selectedTicket.id} {selectedTicket.title}
</div>
<div className="flex items-center gap-2 text-xs text-dark-500">
<span
className={`rounded-full border px-1.5 py-0.5 ${
{
open: 'border-accent-500/30 bg-accent-500/20 text-accent-400',
pending: 'border-warning-500/30 bg-warning-500/20 text-warning-400',
answered: 'border-success-500/30 bg-success-500/20 text-success-400',
closed: 'border-dark-500 bg-dark-600 text-dark-400',
}[selectedTicket.status] || 'border-dark-500 bg-dark-600 text-dark-400'
}`}
>
{selectedTicket.status}
</span>
<span>{formatDate(selectedTicket.created_at)}</span>
</div>
</div>
</div>
{/* Status buttons — 36px mobile / 26px desktop. Active state matters
and gets mis-tapped on mobile when too small. */}
<div className="flex flex-wrap gap-1.5">
{STATUS_VALUES.map((s) => (
<button
key={s}
onClick={() => onStatusChange(s)}
disabled={selectedTicket.status === s || actionLoading}
className={`min-h-[36px] rounded-lg border px-2.5 py-1.5 text-xs transition-all sm:min-h-0 sm:py-1 ${
selectedTicket.status === s
? 'border-accent-500/50 bg-accent-500/20 text-accent-400'
: 'border-dark-700/50 text-dark-400 hover:border-dark-600 hover:text-dark-200'
} disabled:opacity-50`}
>
{t(`admin.tickets.status${s.charAt(0).toUpperCase() + s.slice(1)}`)}
</button>
))}
</div>
{/* Messages */}
<div className="scrollbar-hide max-h-[60vh] space-y-3 overflow-y-auto rounded-xl bg-dark-800/30 p-3">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`rounded-xl p-3 ${
msg.is_from_admin
? 'ml-6 border border-accent-500/20 bg-accent-500/10'
: 'mr-6 border border-dark-700/30 bg-dark-800/50'
}`}
>
<div className="mb-1 flex items-center justify-between">
<span
className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}
>
{msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')}
</span>
<span className="text-xs text-dark-500">{formatDate(msg.created_at)}</span>
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
<MessageMediaGrid message={msg} />
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Reply form */}
{selectedTicket.status !== 'closed' && (
<div className="flex gap-2">
<textarea
value={replyText}
onChange={(e) => onReplyTextChange(e.target.value)}
placeholder={t('admin.tickets.replyPlaceholder')}
rows={2}
className="input flex-1 resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onReply();
}
}}
/>
<button
onClick={onReply}
disabled={!replyText.trim() || replySending}
aria-label={t('admin.tickets.sendReply', 'Send reply')}
className="min-h-[44px] min-w-[44px] shrink-0 self-end rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50 sm:min-h-0 sm:min-w-0"
>
{replySending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
)}
</button>
</div>
)}
</div>
);
}

View File

@@ -58,7 +58,11 @@ export function PermissionRoute({
}
if (!hasAccess) {
return <Navigate to="/admin" replace />;
// Redirect back to the admin landing — unless we're already there
// (would loop) or the landing itself is what we lack permission for.
// Fall back to the user dashboard in that case.
const target = location.pathname === '/admin' ? '/' : '/admin';
return <Navigate to={target} replace />;
}
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform';
import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
/**
* Full-screen block shown when the backend returns
@@ -21,6 +22,7 @@ export default function AccountDeletedScreen() {
const { t } = useTranslation();
const { openTelegramLink } = usePlatform();
const info = useBlockingStore((state) => state.accountDeletedInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
const deepLink = info?.telegram_deep_link?.trim() || null;
// Route through the platform adapter, not raw window.open. Inside the
@@ -42,12 +44,19 @@ export default function AccountDeletedScreen() {
};
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="account-deleted-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center">
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 text-amber-400"
className="h-12 w-12 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -63,9 +72,11 @@ export default function AccountDeletedScreen() {
</div>
</div>
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.accountDeleted.title')}</h1>
<h1 id="account-deleted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.accountDeleted.title')}
</h1>
<p className="mb-6 text-lg text-gray-400">{t('blocking.accountDeleted.description')}</p>
<p className="mb-6 text-lg text-dark-400">{t('blocking.accountDeleted.description')}</p>
<div className="space-y-3">
{deepLink && (
@@ -80,13 +91,13 @@ export default function AccountDeletedScreen() {
<button
type="button"
onClick={handleRetry}
className="block w-full rounded-xl bg-dark-800 px-6 py-3 text-base font-medium text-gray-200 transition-colors hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-dark-950"
className="block w-full rounded-xl bg-dark-800 px-6 py-3 text-base font-medium text-dark-200 transition-colors hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-dark-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.retry')}
</button>
</div>
<p className="mt-8 text-sm text-gray-500">{t('blocking.accountDeleted.hint')}</p>
<p className="mt-8 text-sm text-dark-500">{t('blocking.accountDeleted.hint')}</p>
</div>
</div>
);

View File

@@ -1,18 +1,27 @@
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
export default function BlacklistedScreen() {
const { t } = useTranslation();
const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="blacklisted-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 text-red-500"
className="h-12 w-12 text-error-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -28,20 +37,22 @@ export default function BlacklistedScreen() {
</div>
{/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.blacklisted.title')}</h1>
<h1 id="blacklisted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.blacklisted.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-gray-400">{t('blocking.blacklisted.defaultMessage')}</p>
<p className="mb-6 text-lg text-dark-400">{t('blocking.blacklisted.defaultMessage')}</p>
{/* Reason */}
{blacklistedInfo?.message && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-gray-500">{t('blocking.blacklisted.reason')}:</p>
<p className="text-gray-300">{blacklistedInfo.message}</p>
<p className="mb-1 text-sm text-dark-500">{t('blocking.blacklisted.reason')}:</p>
<p className="text-dark-300">{blacklistedInfo.message}</p>
</div>
)}
<p className="mt-8 text-sm text-gray-500">{t('blocking.blacklisted.contactSupport')}</p>
<p className="mt-8 text-sm text-dark-500">{t('blocking.blacklisted.contactSupport')}</p>
</div>
</div>
);

View File

@@ -2,21 +2,11 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap';
const CHECK_COOLDOWN_SECONDS = 5;
function safeOpenUrl(url: string | undefined | null): void {
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
window.open(url, '_blank', 'noopener');
}
} catch {
// invalid URL, do nothing
}
}
export default function ChannelSubscriptionScreen() {
const { t } = useTranslation();
const channelInfo = useBlockingStore((state) => state.channelInfo);
@@ -25,6 +15,28 @@ export default function ChannelSubscriptionScreen() {
const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null);
const isCheckingRef = useRef(false);
const { openLink, openTelegramLink } = usePlatform();
// Route channel links through the platform adapter: inside the Telegram
// WebView a raw window.open is intercepted by the client and the link
// silently fails to open. t.me links use openTelegramLink; others openLink.
const openChannel = useCallback(
(url: string | undefined | null) => {
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
if (parsed.hostname === 't.me' || parsed.hostname.endsWith('.t.me')) {
openTelegramLink(url);
} else {
openLink(url);
}
} catch {
// invalid URL, do nothing
}
},
[openLink, openTelegramLink],
);
// Cooldown timer
useEffect(() => {
@@ -69,8 +81,17 @@ export default function ChannelSubscriptionScreen() {
}
}, [clearBlocking, t]);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="channel-sub-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
@@ -82,10 +103,12 @@ export default function ChannelSubscriptionScreen() {
</div>
{/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.channel.title')}</h1>
<h1 id="channel-sub-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-gray-400">
<p className="mb-6 text-lg text-dark-400">
{channelInfo?.message || t('blocking.channel.defaultMessage')}
</p>
@@ -95,12 +118,12 @@ export default function ChannelSubscriptionScreen() {
{channels.map((ch) => (
<div
key={ch.channel_id}
className="flex items-center justify-between rounded-xl border border-red-500/30 bg-red-500/10 p-3"
className="flex items-center justify-between rounded-xl border border-error-500/30 bg-error-500/10 p-3"
>
<span className="text-sm font-medium text-white">{ch.title || ch.channel_id}</span>
{ch.channel_link && (
<button
onClick={() => safeOpenUrl(ch.channel_link)}
onClick={() => openChannel(ch.channel_link)}
className="rounded-lg bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-400 hover:bg-blue-500/30"
>
{t('blocking.channel.openChannel')}
@@ -114,8 +137,8 @@ export default function ChannelSubscriptionScreen() {
{/* Fallback: single channel (legacy) */}
{channels.length === 0 && channelInfo?.channel_link && (
<button
onClick={() => safeOpenUrl(channelInfo.channel_link)}
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600"
onClick={() => openChannel(channelInfo.channel_link)}
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-accent-500 px-6 py-4 font-semibold text-white transition-colors duration-200 hover:bg-accent-400"
>
{t('blocking.channel.openChannel')}
</button>
@@ -123,8 +146,8 @@ export default function ChannelSubscriptionScreen() {
{/* Error message */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3">
<p className="text-sm text-red-400">{error}</p>
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{error}</p>
</div>
)}
@@ -161,7 +184,7 @@ export default function ChannelSubscriptionScreen() {
) : cooldown > 0 ? (
<>
<svg
className="h-5 w-5 text-gray-500"
className="h-5 w-5 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -191,7 +214,7 @@ export default function ChannelSubscriptionScreen() {
</button>
{/* Hint */}
<p className="mt-4 text-sm text-gray-500">{t('blocking.channel.hint')}</p>
<p className="mt-4 text-sm text-dark-500">{t('blocking.channel.hint')}</p>
</div>
</div>
);

View File

@@ -1,18 +1,27 @@
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
export default function MaintenanceScreen() {
const { t } = useTranslation();
const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="maintenance-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 text-amber-500"
className="h-12 w-12 text-warning-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -28,38 +37,40 @@ export default function MaintenanceScreen() {
</div>
{/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.maintenance.title')}</h1>
<h1 id="maintenance-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.maintenance.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-gray-400">
<p className="mb-6 text-lg text-dark-400">
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
</p>
{/* Reason */}
{maintenanceInfo?.reason && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-gray-500">{t('blocking.maintenance.reason')}:</p>
<p className="text-gray-300">{maintenanceInfo.reason}</p>
<p className="mb-1 text-sm text-dark-500">{t('blocking.maintenance.reason')}:</p>
<p className="text-dark-300">{maintenanceInfo.reason}</p>
</div>
)}
{/* Decorative dots */}
<div className="mt-8 flex items-center justify-center gap-2">
<div
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '0ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '300ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '600ms' }}
/>
</div>
<p className="mt-4 text-sm text-gray-500">{t('blocking.maintenance.waitMessage')}</p>
<p className="mt-4 text-sm text-dark-500">{t('blocking.maintenance.waitMessage')}</p>
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../../hooks/useFocusTrap';
interface PreviewButton {
text: string;
@@ -105,13 +106,13 @@ function wrap(frame: Frame, key: number): ReactNode {
return <s key={k}>{frame.children}</s>;
case 'code':
return (
<code key={k} className="rounded bg-black/30 px-1 font-mono text-[0.92em]">
<code key={k} className="rounded bg-dark-950/30 px-1 font-mono text-[0.92em]">
{frame.children}
</code>
);
case 'pre':
return (
<pre key={k} className="my-1 rounded bg-black/30 p-2 font-mono text-[0.92em]">
<pre key={k} className="my-1 rounded bg-dark-950/30 p-2 font-mono text-[0.92em]">
{frame.children}
</pre>
);
@@ -185,13 +186,19 @@ export function TelegramPreview({
}: TelegramPreviewProps) {
const { t } = useTranslation();
const rendered = useMemo(() => tokensToReact(tokenize(text)), [text]);
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 p-4"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-dark-950/70 p-4"
onClick={onClose}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
tabIndex={-1}
className="w-full max-w-md rounded-2xl bg-[#17212b] p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
@@ -199,14 +206,23 @@ export function TelegramPreview({
<h3 className="text-base font-semibold text-white">
{t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
</h3>
<button onClick={onClose} className="rounded p-1 text-dark-400 hover:bg-dark-700">
<button
onClick={onClose}
aria-label={t('common.close', 'Закрыть')}
className="flex h-9 w-9 items-center justify-center rounded text-dark-400 hover:bg-dark-700"
>
</button>
</div>
<div className="rounded-xl bg-[#0e1621] p-3">
<div className="ml-auto max-w-[90%] rounded-2xl rounded-tr-md bg-[#2b5278] p-3 text-white shadow">
{mediaUrl && mediaType === 'photo' && (
<img src={mediaUrl} alt="" className="mb-2 max-h-72 w-full rounded-lg object-cover" />
<img
src={mediaUrl}
alt=""
loading="lazy"
className="mb-2 max-h-72 w-full rounded-lg object-cover"
/>
)}
{mediaUrl && mediaType === 'video' && (
<video src={mediaUrl} controls className="mb-2 max-h-72 w-full rounded-lg" />
@@ -247,14 +263,20 @@ export function TelegramPreview({
export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) {
const { t } = useTranslation();
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
if (!open) return null;
const emptyHtml = `<p style="color:#999;font-family:sans-serif">${t('admin.broadcasts.previewEmpty', '— пусто —')}</p>`;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 p-4"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-dark-950/70 p-4"
onClick={onClose}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={subject || t('admin.broadcasts.emailSubject', 'Email')}
tabIndex={-1}
className="flex h-[80vh] w-full max-w-2xl flex-col rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
@@ -269,7 +291,8 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
</div>
<button
onClick={onClose}
className="ml-3 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
aria-label={t('common.close', 'Закрыть')}
className="ml-3 flex h-9 w-9 items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
</button>

View File

@@ -3,10 +3,10 @@ interface PageLoaderProps {
}
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-accent-500';
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-viewport flex items-center justify-center">
<div
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
/>

View File

@@ -197,6 +197,7 @@ export default function InstallationGuide({
{!isTelegramWebApp && (
<button
onClick={onGoBack}
aria-label={t('common.back', 'Back')}
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 />
@@ -208,6 +209,7 @@ export default function InstallationGuide({
{appConfig.subscriptionUrl && onOpenQR && (
<button
onClick={() => onOpenQR()}
aria-label={t('subscription.connection.openQr', 'Open QR code')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-200 transition-colors hover:border-dark-600"
>
<svg
@@ -296,7 +298,7 @@ export default function InstallationGuide({
: 'border border-dark-700/50 bg-dark-800/80 text-dark-200 hover:border-dark-600/50 hover:bg-dark-700/80'
}`}
>
{app.featured && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />}
{app.featured && <span className="h-2 w-2 shrink-0 rounded-full bg-warning-400" />}
<span className="relative z-10 truncate">{app.name}</span>
{appIconSvg && (
<div

View File

@@ -256,9 +256,9 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
{/* QR Scanner */}
<div className={cardClass}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10">
<svg
className="h-5 w-5 text-blue-500"
className="h-5 w-5 text-accent-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -323,7 +323,7 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
{toast && (
<div
className={`fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl px-5 py-3 text-sm font-medium shadow-lg transition-all ${
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
toast.type === 'success' ? 'bg-success-500/90 text-white' : 'bg-error-500/90 text-white'
}`}
>
{toast.text}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard';
// eslint-disable-next-line no-script-url
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
@@ -64,16 +65,7 @@ export function BlockButtons({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
await copyToClipboard(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);

View File

@@ -72,7 +72,7 @@ export default function SubscriptionCardActive({
return (
<div
className="relative overflow-hidden rounded-3xl backdrop-blur-xl"
className="relative overflow-hidden rounded-3xl lg:backdrop-blur-xl"
style={{
background: g.cardBg,
border: subscription.is_trial
@@ -86,28 +86,9 @@ export default function SubscriptionCardActive({
: `0 2px 16px rgba(${zone.mainVarRaw}, 0.07), 0 0 0 1px rgba(${zone.mainVarRaw}, 0.03)`,
}}
>
{/* Trial shimmer border */}
{subscription.is_trial && (
<div
className="pointer-events-none absolute inset-[-1px] animate-trial-glow rounded-3xl"
aria-hidden="true"
/>
)}
{/* Background glow */}
<div
className="pointer-events-none absolute"
style={{
top: -60,
right: -60,
width: 200,
height: 200,
borderRadius: '50%',
background: `radial-gradient(circle, rgba(${zone.mainVarRaw}, ${isDark ? 0.08 : 0.03}) 0%, transparent 70%)`,
transition: 'background 0.8s ease',
}}
aria-hidden="true"
/>
{/* Decorative trial-shimmer border + ambient background glow removed.
Trial state is conveyed by the badge in the header; ambient glow
carried no information and ate visual attention. */}
{/* ─── Header ─── */}
<div className="mb-7 flex items-start justify-between">
@@ -118,8 +99,7 @@ export default function SubscriptionCardActive({
className="h-2 w-2 rounded-full"
style={{
background: zone.mainVar,
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.5)`,
transition: 'all 0.6s ease',
transition: 'background 0.6s ease',
}}
aria-hidden="true"
/>
@@ -130,15 +110,7 @@ export default function SubscriptionCardActive({
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
</span>
{subscription.is_trial && (
<span
className="inline-flex animate-trial-glow items-center gap-1 rounded-md px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest"
style={{
background:
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.15), rgba(var(--color-accent-400), 0.06))',
border: '1px solid rgba(var(--color-accent-400), 0.2)',
color: 'rgb(var(--color-accent-400))',
}}
>
<span className="inline-flex items-center gap-1 rounded-md border border-accent-400/25 bg-accent-400/10 px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest text-accent-400">
<svg
width="10"
height="10"
@@ -310,18 +282,20 @@ export default function SubscriptionCardActive({
{/* ─── Stats row: Tariff + Days Left ─── */}
<div className="mb-5 flex gap-2.5">
{/* Tariff badge — clickable */}
{/* Tariff badge — clickable. Neutral chrome: the tariff name has
no traffic-zone semantics, so tinting it by the traffic zone
(DESIGN.md Status-Hue Lockout) was wrong. */}
<Link
to={`/subscriptions/${subscription.id}`}
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
className="flex-1 rounded-[14px] p-3.5 transition-colors"
style={{
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
border: `1px solid rgba(${zone.mainVarRaw}, 0.09)`,
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div
className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider opacity-70 transition-colors duration-500"
style={{ color: zone.mainVar }}
className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: g.textFaint }}
>
{t('dashboard.tariff')}
</div>

View File

@@ -107,14 +107,14 @@ export default function SubscriptionCardExpired({
r: 255,
g: 184,
b: 0,
hex: '#FFB800',
hex: 'rgb(var(--color-urgent-400))',
gradient: 'linear-gradient(135deg, #FFB800, #FF8C00)',
}
: {
r: 255,
g: 59,
b: 92,
hex: '#FF3B5C',
hex: 'rgb(var(--color-critical-500))',
gradient: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
};

View File

@@ -113,13 +113,14 @@ export default function TrafficProgressBar({
<div style={{ flex: '10 0 0', background: 'rgba(var(--color-error-400), 0.05)' }} />
</div>
{/* Fill bar */}
{/* Fill bar — scaleX (compositor) instead of width (layout reflow).
Use top-left origin so fill grows left→right same as width. */}
<div
className="absolute bottom-0 left-0 top-0 overflow-hidden"
className="absolute bottom-0 left-0 top-0 w-full origin-left overflow-hidden"
style={{
width: `${clampedPercent}%`,
transform: `scaleX(${clampedPercent / 100})`,
borderRadius: 10,
transition: 'width 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
transition: 'transform 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Gradient fill */}
@@ -187,7 +188,7 @@ export default function TrafficProgressBar({
{/* Scale labels */}
{!compact && limitGb > 0 && (
<div
className="mt-1.5 flex justify-between px-0.5 font-mono text-[9px] font-medium text-dark-50/20"
className="mt-1.5 flex justify-between px-0.5 font-mono text-[10px] font-medium text-dark-50/20"
aria-hidden="true"
>
{[0, 25, 50, 75, 100].map((v) => (

View File

@@ -115,7 +115,7 @@ export default function TrialOfferCard({
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="#FFB800"
stroke="rgb(var(--color-urgent-400))"
strokeWidth="1.5"
aria-hidden="true"
>
@@ -157,11 +157,14 @@ export default function TrialOfferCard({
>
<span
className="text-[32px] font-extrabold leading-none tracking-tight"
style={{ color: '#FFB800' }}
style={{ color: 'rgb(var(--color-urgent-400))' }}
>
{trialInfo.price_rubles.toFixed(0)}
</span>
<span className="text-base font-semibold opacity-70" style={{ color: '#FFB800' }}>
<span
className="text-base font-semibold opacity-70"
style={{ color: 'rgb(var(--color-urgent-400))' }}
>
{currencySymbol}
</span>
</div>

View File

@@ -701,3 +701,36 @@ export const ArrowPathIcon = ({ className }: IconProps) => (
/>
</svg>
);
export const PauseIcon = ({ className, style }: IconProps & { style?: React.CSSProperties }) => (
<svg
className={cn('h-5 w-5', className)}
style={style}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
export const CreditCardIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
);

View File

@@ -310,7 +310,10 @@ export function AppHeader({
style={{ top: headerHeight }}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileMenuOpen(false)} />
<div
className="absolute inset-0 bg-dark-950/60"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Menu content */}
<div

View File

@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import { PromptDialogHost } from '@/components/PromptDialogHost';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell';
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
@@ -278,7 +279,7 @@ export function AppShell({ children }: AppShellProps) {
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
return (
<div className="min-h-screen">
<div className="min-h-viewport">
{/* Animated background renders via portal on document.body at z-index: -1 */}
<BackgroundRenderer />
@@ -286,6 +287,7 @@ export function AppShell({ children }: AppShellProps) {
<WebSocketNotifications />
<CampaignBonusNotifier />
<SuccessNotificationModal />
<PromptDialogHost />
{/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
@@ -322,6 +324,7 @@ export function AppShell({ children }: AppShellProps) {
key={item.path}
to={item.path}
onClick={handleNavClick}
aria-label={item.label}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive(item.path)
@@ -330,7 +333,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{item.label}
</span>
</Link>
@@ -339,6 +342,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/referral"
onClick={handleNavClick}
aria-label={t('nav.referral')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/referral')
@@ -347,7 +351,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<UsersIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.referral')}
</span>
</Link>
@@ -356,6 +360,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/gift"
onClick={handleNavClick}
aria-label={t('nav.gift')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/gift')
@@ -364,7 +369,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<GiftIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.gift')}
</span>
</Link>
@@ -375,6 +380,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/admin"
onClick={handleNavClick}
aria-label={t('admin.nav.title')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
location.pathname.startsWith('/admin')
@@ -383,7 +389,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<ShieldIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('admin.nav.title')}
</span>
</Link>
@@ -402,6 +408,9 @@ export function AppShell({ children }: AppShellProps) {
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400',
!canToggleTheme && 'pointer-events-none invisible',
)}
aria-label={
isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'
}
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}

View File

@@ -96,7 +96,7 @@ export function DesktopSidebar({
};
return (
<aside className="fixed left-0 top-0 z-40 flex h-screen w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
<aside className="h-viewport fixed left-0 top-0 z-40 flex w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-dark-700/30 px-4">
<Link to="/" className="flex items-center gap-3" onClick={handleNavClick}>

View File

@@ -26,16 +26,29 @@ export function MobileBottomNav({
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
// Core navigation items for bottom bar
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
// Core navigation items for bottom bar.
//
// Support is ALWAYS present — frustrated paying customers must find help
// in the primary nav, not in the hamburger drawer. Previously Wheel
// (a brand-moment surface) displaced Support (a critical-path surface)
// when the wheel feature flag was on; that trade is hostile to the
// support-user persona and was flagged by the /impeccable critique.
//
// Slot priority when both Wheel and Referral are enabled and only
// four slots remain after Dashboard / Subscriptions / Balance / Support:
// - Wheel wins (operator opted in as a deliberate brand moment)
// - Referral falls back to the hamburger drawer
// When only one of them is enabled, that one fills the slot.
const coreItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
...(wheelEnabled
? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }]
: [{ path: '/support', label: t('nav.support'), icon: ChatIcon }]),
: referralEnabled
? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }]
: []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
];
const handleNavClick = () => {

View File

@@ -123,7 +123,7 @@ export function CommandPalette({
{/* Backdrop */}
<DialogPrimitive.Overlay asChild>
<motion.div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-dark-950/60 backdrop-blur-sm"
variants={backdrop}
initial="initial"
animate="animate"

View File

@@ -54,7 +54,7 @@ export const DialogOverlay = forwardRef<HTMLDivElement, DialogOverlayProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
className={cn('fixed inset-0 z-50 bg-dark-950/60 backdrop-blur-sm', className)}
asChild
{...props}
>

View File

@@ -91,7 +91,7 @@ export const SheetOverlay = forwardRef<HTMLDivElement, SheetOverlayProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
className={cn('fixed inset-0 z-50 bg-dark-950/60 backdrop-blur-sm', className)}
asChild
{...props}
>

View File

@@ -48,7 +48,7 @@ export function AddonsTab({ params }: AddonsTabProps) {
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const packageBarData = data.by_package.map((item) => ({

View File

@@ -68,7 +68,7 @@ export function DepositsTab({ params }: DepositsTabProps) {
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
return (

View File

@@ -35,7 +35,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) {
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const dailyData = data.daily.map((item) => ({

View File

@@ -45,7 +45,7 @@ export function SalesTab({ params }: SalesTabProps) {
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const tariffBarData = data.by_tariff.map((item) => ({

View File

@@ -42,7 +42,7 @@ export function TrialsTab({ params }: TrialsTabProps) {
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const pieData = data.by_provider.map((item) => ({

View File

@@ -24,7 +24,7 @@ export default function PurchaseCTAButton({
// Daily tariffs renew automatically — no manual renewal button needed in multi-tariff
if (isMultiTariff && isDaily && !isExpired) return null;
const accentColor = isExpired ? '#FF3B5C' : 'rgb(var(--color-accent-400))';
const accentColor = isExpired ? 'rgb(var(--color-critical-500))' : 'rgb(var(--color-accent-400))';
const buttonText = isExpired
? t('subscription.getSubscription')

View File

@@ -32,7 +32,7 @@ function StatusBadge({
if (isTrial) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">
<span className="inline-flex items-center gap-1 rounded-full border border-warning-400/25 bg-warning-400/10 px-2 py-0.5 text-[10px] font-semibold text-warning-400">
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
</svg>
@@ -42,10 +42,10 @@ function StatusBadge({
}
const color = isActive
? 'bg-emerald-400/15 text-emerald-400 border-emerald-400/20'
? 'bg-success-400/15 text-success-400 border-success-400/20'
: isLimited
? 'bg-amber-400/15 text-amber-400 border-amber-400/20'
: 'bg-red-400/15 text-red-400 border-red-400/20';
? 'bg-warning-400/15 text-warning-400 border-warning-400/20'
: 'bg-error-400/15 text-error-400 border-error-400/20';
const label = isActive
? t('subscription.statusActive', 'Активна')
@@ -96,7 +96,11 @@ export default function SubscriptionListCard({
? Math.min(100, (trafficUsed / trafficLimit) * 100)
: 0;
const trafficColor =
trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
trafficPercent >= 90
? 'bg-error-400'
: trafficPercent >= 70
? 'bg-warning-400'
: 'bg-success-400';
const isLimitedStatus = subscription.status === 'limited';
@@ -207,7 +211,7 @@ export default function SubscriptionListCard({
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-emerald-400/70' : 'text-red-400/50'}`}
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
>
<svg
className="h-3 w-3"

View File

@@ -0,0 +1,602 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { useTheme } from '../../../hooks/useTheme';
import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { useCloseOnSuccessNotification } from '../../../store/successNotification';
import { getGlassColors } from '../../../utils/glassTheme';
import { getErrorMessage, type PurchaseStep } from '../../../utils/subscriptionHelpers';
import { CheckIcon } from '../../icons';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import Twemoji from 'react-twemoji';
import type {
ClassicPurchaseOptions,
PeriodOption,
PurchaseSelection,
Subscription,
} from '../../../types';
// ──────────────────────────────────────────────────────────────────
// ClassicPurchaseWizard
//
// The classic-mode (non-tariff) purchase flow: a step wizard with
// period → traffic (optional) → servers (when >1) → devices
// (optional) → confirm. Self-owns:
// - the preview query (gated to confirm step)
// - the submitPurchase mutation
// - all six pieces of wizard state (currentStep, selectedPeriod,
// selectedTraffic, selectedServers, selectedDevices, showForm)
// - the steps memo + currentSelection memo
// - the init effect that seeds defaults from classicOptions.selection
// - useCloseOnSuccessNotification (resets form + step on global
// success notification)
//
// Parent only supplies the loaded options and the subscription
// context. Replaces ~411 lines of inline JSX in SubscriptionPurchase.
// ──────────────────────────────────────────────────────────────────
export interface ClassicPurchaseWizardProps {
classicOptions: ClassicPurchaseOptions;
subscription: Subscription | null;
subscriptionId: number | undefined;
}
export function ClassicPurchaseWizard({
classicOptions,
subscription,
subscriptionId,
}: ClassicPurchaseWizardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { formatAmount, currencySymbol } = useCurrency();
const { activeDiscount, applyPromoDiscount } = usePromoDiscount();
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Wizard state
const [currentStep, setCurrentStep] = useState<PurchaseStep>('period');
const [selectedPeriod, setSelectedPeriod] = useState<PeriodOption | null>(null);
const [selectedTraffic, setSelectedTraffic] = useState<number | null>(null);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [selectedDevices, setSelectedDevices] = useState<number>(1);
const [showPurchaseForm, setShowPurchaseForm] = useState(false);
// Global success-notification handler — resets back to the closed,
// period-step state. Mirrors the parent's old handleCloseAllModals
// contract for this flow.
useCloseOnSuccessNotification(() => {
setShowPurchaseForm(false);
setCurrentStep('period');
});
const getAvailableServers = useCallback(
(period: PeriodOption | null) => {
if (!period?.servers.options) return [];
return period.servers.options.filter((server) => {
if (!server.is_available) return false;
if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false;
return true;
});
},
[subscription?.is_trial],
);
const steps = useMemo<PurchaseStep[]>(() => {
const result: PurchaseStep[] = ['period'];
if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) {
result.push('traffic');
}
const availableServers = getAvailableServers(selectedPeriod);
if (availableServers.length > 1) {
result.push('servers');
}
if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) {
result.push('devices');
}
result.push('confirm');
return result;
}, [selectedPeriod, getAvailableServers]);
const currentStepIndex = steps.indexOf(currentStep);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
// Seed defaults from classicOptions.selection on first mount.
useEffect(() => {
if (!selectedPeriod) {
const defaultPeriod =
classicOptions.periods.find((p) => p.id === classicOptions.selection.period_id) ||
classicOptions.periods[0];
setSelectedPeriod(defaultPeriod);
setSelectedTraffic(classicOptions.selection.traffic_value);
const availableServers = getAvailableServers(defaultPeriod);
const availableServerUuids = new Set(availableServers.map((s) => s.uuid));
if (availableServers.length === 1) {
setSelectedServers([availableServers[0].uuid]);
} else {
setSelectedServers(
classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)),
);
}
setSelectedDevices(classicOptions.selection.devices);
}
}, [classicOptions, selectedPeriod, getAvailableServers]);
const currentSelection: PurchaseSelection = useMemo(
() => ({
period_id: selectedPeriod?.id,
period_days: selectedPeriod?.period_days,
traffic_value: selectedTraffic ?? undefined,
servers: selectedServers,
devices: selectedDevices,
}),
[selectedPeriod, selectedTraffic, selectedServers, selectedDevices],
);
const { data: preview, isLoading: previewLoading } = useQuery({
queryKey: ['purchase-preview', currentSelection],
queryFn: () => subscriptionApi.previewPurchase(currentSelection, subscriptionId),
enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm',
});
const purchaseMutation = useMutation({
mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
},
});
const toggleServer = (uuid: string) => {
if (selectedServers.includes(uuid)) {
if (selectedServers.length > 1) {
setSelectedServers(selectedServers.filter((s) => s !== uuid));
}
} else {
setSelectedServers([...selectedServers, uuid]);
}
};
const goToNextStep = () => {
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
setCurrentStep(steps[nextIndex]);
}
};
const goToPrevStep = () => {
const prevIndex = currentStepIndex - 1;
if (prevIndex >= 0) {
setCurrentStep(steps[prevIndex]);
}
};
const resetPurchase = () => {
setShowPurchaseForm(false);
setCurrentStep('period');
};
const getStepLabel = (step: PurchaseStep) => {
switch (step) {
case 'period':
return t('subscription.stepPeriod');
case 'traffic':
return t('subscription.stepTraffic');
case 'servers':
return t('subscription.stepServers');
case 'devices':
return t('subscription.stepDevices');
case 'confirm':
return t('subscription.stepConfirm');
}
};
return (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold tracking-tight text-dark-50">
{subscription && !subscription.is_trial
? t('subscription.extend')
: t('subscription.getSubscription')}
</h2>
{!showPurchaseForm && (
<button onClick={() => setShowPurchaseForm(true)} className="btn-primary">
{subscription && !subscription.is_trial
? t('subscription.extend')
: t('subscription.getSubscription')}
</button>
)}
</div>
{showPurchaseForm && (
<div className="space-y-6">
{/* Step Indicator */}
<div className="mb-6 flex items-center justify-between">
<div className="text-sm text-dark-400">
{t('subscription.step', { current: currentStepIndex + 1, total: steps.length })}
</div>
<div className="flex gap-2">
{steps.map((step, idx) => (
<div
key={step}
className={`h-1 w-8 rounded-full transition-colors ${
idx <= currentStepIndex ? 'bg-accent-500' : 'bg-dark-700'
}`}
/>
))}
</div>
</div>
<div className="mb-4 text-lg font-medium text-dark-100">{getStepLabel(currentStep)}</div>
{/* Step: Period Selection */}
{currentStep === 'period' && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{classicOptions.periods.map((period) => {
const promoPeriod = applyPromoDiscount(
period.price_kopeks,
period.original_price_kopeks,
);
return (
<button
key={period.id}
onClick={() => {
setSelectedPeriod(period);
if (period.traffic.current !== undefined) {
setSelectedTraffic(period.traffic.current);
}
const availableServers = getAvailableServers(period);
if (availableServers.length === 1) {
setSelectedServers([availableServers[0].uuid]);
} else if (period.servers.selected) {
const availUuids = new Set(availableServers.map((s) => s.uuid));
setSelectedServers(
period.servers.selected.filter((uuid) => availUuids.has(uuid)),
);
}
if (period.devices.current) {
setSelectedDevices(period.devices.current);
}
}}
className={`bento-card-hover relative p-4 text-left transition-all ${
selectedPeriod?.id === period.id ? 'bento-card-glow border-accent-500' : ''
}`}
>
{promoPeriod.percent && promoPeriod.percent > 0 && (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
}`}
>
-{promoPeriod.percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)}
</span>
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-sm text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</button>
);
})}
</div>
)}
{/* Step: Traffic Selection */}
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{selectedPeriod.traffic.options.map((option) => {
const promoTraffic = applyPromoDiscount(
option.price_kopeks,
option.original_price_kopeks,
);
return (
<button
key={option.value}
onClick={() => setSelectedTraffic(option.value)}
disabled={!option.is_available}
className={`bento-card-hover relative p-4 text-center transition-all ${
selectedTraffic === option.value ? 'bento-card-glow border-accent-500' : ''
} ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`}
>
{promoTraffic.percent && promoTraffic.percent > 0 && (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoTraffic.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
}`}
>
-{promoTraffic.percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
{promoTraffic.original && promoTraffic.original > promoTraffic.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoTraffic.original)}
</span>
)}
</div>
</button>
);
})}
</div>
)}
{/* Step: Server Selection */}
{currentStep === 'servers' && selectedPeriod?.servers.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{selectedPeriod.servers.options
.filter((server) => {
if (!server.is_available) return false;
if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) {
return false;
}
return true;
})
.map((server) => {
const promoServer = applyPromoDiscount(
server.price_kopeks,
server.original_price_kopeks,
);
return (
<button
key={server.uuid}
onClick={() => toggleServer(server.uuid)}
disabled={!server.is_available}
className={`relative rounded-xl border p-4 text-left transition-all ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500/10'
: server.is_available
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
}`}
>
{promoServer.percent && promoServer.percent > 0 ? (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoServer.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
}`}
>
-{promoServer.percent}%
</div>
) : null}
<div className="flex items-center gap-3">
<div
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500'
: 'border-dark-600'
}`}
>
{selectedServers.includes(server.uuid) && <CheckIcon />}
</div>
<div>
<div className="font-medium text-dark-100">
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
{server.name}
</Twemoji>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm text-accent-400">
{formatPrice(promoServer.price)}
{t('subscription.perMonth')}
</span>
{promoServer.original && promoServer.original > promoServer.price ? (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoServer.original)}
</span>
) : null}
</div>
</div>
</div>
</button>
);
})}
</div>
)}
{/* Step: Device Selection */}
{currentStep === 'devices' && selectedPeriod && (
<div className="flex flex-col items-center py-8">
<div className="flex items-center gap-6">
<button
onClick={() =>
setSelectedDevices(Math.max(selectedPeriod.devices.min, selectedDevices - 1))
}
disabled={selectedDevices <= selectedPeriod.devices.min}
className="btn-secondary flex h-14 w-14 items-center justify-center !p-0 text-2xl"
>
-
</button>
<div className="text-center">
<div className="text-5xl font-bold text-dark-100">{selectedDevices}</div>
<div className="mt-2 text-dark-500">{t('subscription.devices')}</div>
</div>
<button
onClick={() =>
setSelectedDevices(Math.min(selectedPeriod.devices.max, selectedDevices + 1))
}
disabled={selectedDevices >= selectedPeriod.devices.max}
className="btn-secondary flex h-14 w-14 items-center justify-center !p-0 text-2xl"
>
+
</button>
</div>
<div className="mt-4 space-y-1 text-center text-sm text-dark-500">
<div className="text-accent-400">
{t('subscription.devicesFree', { count: selectedPeriod.devices.min })}
</div>
{selectedPeriod.devices.max > selectedPeriod.devices.min && (
<div>
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '}
{t('subscription.perExtraDevice')}
</div>
)}
</div>
</div>
)}
{/* Step: Confirm */}
{currentStep === 'confirm' && (
<div>
{previewLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : preview ? (
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
{activeDiscount?.is_active && activeDiscount.discount_percent && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-3">
<svg
className="h-4 w-4 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
<span className="text-sm font-medium text-warning-400">
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
</span>
</div>
)}
{preview.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-dark-300">
<span>{item.label}</span>
<span>{item.value}</span>
</div>
))}
{(() => {
const promoTotal = applyPromoDiscount(
preview.total_price_kopeks,
preview.original_price_kopeks,
);
return (
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
<span className="text-lg font-semibold text-dark-100">
{t('subscription.total')}
</span>
<div className="text-right">
<div className="text-2xl font-bold text-accent-400">
{formatPrice(promoTotal.price)}
</div>
{promoTotal.original && promoTotal.original > promoTotal.price && (
<div className="text-sm text-dark-500 line-through">
{formatPrice(promoTotal.original)}
</div>
)}
</div>
</div>
);
})()}
{preview.discount_label && (
<div className="text-center text-sm text-success-400">
{preview.discount_label}
</div>
)}
{!preview.can_purchase &&
(preview.missing_amount_kopeks > 0 ? (
<InsufficientBalancePrompt
missingAmountKopeks={preview.missing_amount_kopeks}
compact
/>
) : preview.status_message ? (
<div className="rounded-lg bg-error-500/10 px-4 py-3 text-center text-sm text-error-400">
{preview.status_message}
</div>
) : null)}
</div>
) : null}
</div>
)}
{/* Navigation Buttons */}
<div className="flex gap-3 border-t border-dark-800/50 pt-4">
{!isFirstStep && (
<button onClick={goToPrevStep} className="btn-secondary flex-1">
{t('common.back')}
</button>
)}
{isFirstStep && (
<button onClick={resetPurchase} className="btn-secondary">
{t('common.cancel')}
</button>
)}
{!isLastStep ? (
<button
onClick={goToNextStep}
disabled={!selectedPeriod}
className="btn-primary flex-1"
>
{t('common.next')}
</button>
) : (
<button
onClick={() => purchaseMutation.mutate()}
disabled={purchaseMutation.isPending || previewLoading || !preview?.can_purchase}
className="btn-primary flex-1"
>
{purchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.purchase')
)}
</button>
)}
</div>
{purchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(purchaseMutation.error)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,347 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useTheme } from '../../../hooks/useTheme';
import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { getGlassColors } from '../../../utils/glassTheme';
import type { Tariff, Subscription, PurchaseOptions } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// TariffPickerGrid
//
// The tariff selection surface inside SubscriptionPurchase. Renders:
// - an optional promo-group banner when any tariff carries a
// promo_group_name
// - the "all tariffs purchased" empty state (multi-tariff mode)
// - the grid itself (1 col mobile, 2 cols sm+) with promo prices,
// per-tariff CTAs differentiated by user state (extend / switch /
// purchase / legacy renewal)
//
// Owns nothing — pure presentation that calls back into the parent
// for selection (`onSelectTariff`) and switch (`onSwitchTariff`).
// ──────────────────────────────────────────────────────────────────
export interface TariffPickerGridProps {
tariffs: Tariff[];
subscription: Subscription | null;
purchaseOptions: PurchaseOptions | undefined;
isTariffsMode: boolean;
isMultiTariff: boolean;
onSelectTariff: (tariff: Tariff) => void;
onSwitchTariff: (tariffId: number) => void;
}
export function TariffPickerGrid({
tariffs,
subscription,
purchaseOptions,
isTariffsMode,
isMultiTariff,
onSelectTariff,
onSwitchTariff,
}: TariffPickerGridProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { formatAmount, currencySymbol } = useCurrency();
const { applyPromoDiscount } = usePromoDiscount();
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
return (
<>
{/* Promo group discount banner */}
{tariffs.some((tariff) => tariff.promo_group_name) && (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/20 text-success-400">
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
</div>
<div>
<div className="text-sm font-medium text-success-400">
{t('subscription.promoGroup.title', {
name: tariffs.find((tariff) => tariff.promo_group_name)?.promo_group_name,
})}
</div>
<div className="text-xs text-dark-400">
{t('subscription.promoGroup.personalDiscountsApplied')}
</div>
</div>
</div>
)}
{/* Tariff Grid */}
{isMultiTariff &&
purchaseOptions &&
'all_tariffs_purchased' in purchaseOptions &&
purchaseOptions.all_tariffs_purchased && (
<div
className="rounded-2xl border p-6 text-center"
style={{ background: g.cardBg, borderColor: g.cardBorder }}
>
<div className="mb-2 text-3xl"></div>
<h3 className="mb-1 text-lg font-semibold" style={{ color: g.text }}>
{t('subscription.allTariffsPurchased', 'Все тарифы подключены')}
</h3>
<p className="mb-4 text-sm" style={{ color: g.textSecondary }}>
{t(
'subscription.allTariffsPurchasedDesc',
'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.',
)}
</p>
<button
onClick={() => navigate('/subscriptions')}
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('subscription.backToList', 'Мои подписки')}
</button>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[...tariffs]
.filter((tariff) => {
// In multi-tariff mode: hide already purchased tariffs
if (isMultiTariff && tariff.is_purchased) return false;
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
return false;
}
return true;
})
.sort((a, b) => {
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id;
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id;
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return 0;
})
.map((tariff) => {
const isCurrentTariff = tariff.is_current || tariff.id === subscription?.tariff_id;
const isSubscriptionExpired =
isTariffsMode &&
purchaseOptions &&
'subscription_is_expired' in purchaseOptions &&
purchaseOptions.subscription_is_expired === true;
const canSwitch =
!isMultiTariff &&
subscription &&
subscription.tariff_id &&
!isCurrentTariff &&
!subscription.is_trial &&
!isSubscriptionExpired &&
(subscription.is_active || subscription.is_limited);
const isLegacySubscription =
subscription && !subscription.is_trial && !subscription.tariff_id;
return (
<div
key={tariff.id}
className={`bento-card-hover p-5 text-left transition-all ${
isCurrentTariff ? 'bento-card-glow border-accent-500' : ''
}`}
>
<div className="mb-3 flex items-start justify-between">
<div>
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
{tariff.description && (
<div className="mt-1 whitespace-pre-line text-sm text-dark-400">
{tariff.description}
</div>
)}
</div>
{isCurrentTariff && (
<span className="badge-success text-xs">{t('subscription.currentTariff')}</span>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
<span className="font-medium text-dark-200">{tariff.traffic_limit_label}</span>
</div>
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span className="text-dark-300">
{tariff.device_limit === 0
? '∞'
: t('subscription.devices', { count: tariff.device_limit })}
</span>
</div>
{tariff.traffic_reset_mode && tariff.traffic_reset_mode !== 'NO_RESET' && (
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.182"
/>
</svg>
<span className="text-dark-300">
{t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)}
</span>
</div>
)}
</div>
{/* Price info */}
<div className="mt-3 border-t border-dark-700/50 pt-3 text-sm text-dark-400">
{(() => {
const dailyPrice =
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
if (dailyPrice > 0 || originalDailyPrice > 0) {
const promoDaily = applyPromoDiscount(
dailyPrice,
originalDailyPrice > dailyPrice ? originalDailyPrice : undefined,
);
return (
<span className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(promoDaily.price)}
</span>
{promoDaily.original && promoDaily.original > promoDaily.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoDaily.original)}
</span>
)}
<span>{t('subscription.tariff.perDay')}</span>
{promoDaily.percent && promoDaily.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoDaily.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
-{promoDaily.percent}%
</span>
)}
</span>
);
}
if (tariff.periods.length > 0) {
const firstPeriod = tariff.periods[0];
const promoPeriod = applyPromoDiscount(
firstPeriod?.price_kopeks || 0,
firstPeriod?.original_price_kopeks,
);
return (
<span className="flex flex-wrap items-center gap-2">
<span>{t('subscription.from')}</span>
<span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)}
</span>
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
{promoPeriod.percent && promoPeriod.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoPeriod.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
-{promoPeriod.percent}%
</span>
)}
</span>
);
}
return (
<span className="font-medium text-accent-400">
{t('subscription.tariff.flexiblePayment')}
</span>
);
})()}
</div>
{/* Action Buttons */}
<div className="mt-4 flex gap-2">
{isCurrentTariff ? (
subscription?.is_daily ? (
<div className="flex-1 py-2 text-center text-sm text-dark-500">
{t('subscription.currentTariff')}
</div>
) : (
<button
onClick={() => onSelectTariff(tariff)}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.extend')}
</button>
)
) : isLegacySubscription ? (
<button
onClick={() => onSelectTariff(tariff)}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.tariff.selectForRenewal')}
</button>
) : canSwitch ? (
<button
onClick={() => onSwitchTariff(tariff.id)}
className="btn-secondary flex-1 py-2 text-sm"
>
{t('subscription.switchTariff.switch')}
</button>
) : (
<button
onClick={() => onSelectTariff(tariff)}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.purchase')}
</button>
)}
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,641 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers';
import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff, TariffPeriod } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// TariffPurchaseForm
//
// The full per-tariff purchase form: period picker (or daily-tariff
// activate), custom-days toggle + slider, custom-traffic toggle +
// slider, summary, and the confirm CTA. Self-owns:
// - the purchaseTariff mutation
// - the auto-scroll-into-view ref + effect on mount
// - selectedTariffPeriod / customDays / customTrafficGb /
// useCustomDays / useCustomTraffic (form-internal state, reset
// by re-mount when the parent passes a new `tariff` via key=)
//
// The parent (SubscriptionPurchase) supplies the chosen tariff,
// the current balance (for inline insufficient-balance prompts),
// the subscription id (for the renew-this-subscription flow), and
// onBack to clear its own selection state.
// ──────────────────────────────────────────────────────────────────
export interface TariffPurchaseFormProps {
tariff: Tariff;
subscriptionId: number | undefined;
balanceKopeks: number | undefined;
onBack: () => void;
}
export function TariffPurchaseForm({
tariff,
subscriptionId,
balanceKopeks,
onBack,
}: TariffPurchaseFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const { applyPromoDiscount } = usePromoDiscount();
const ref = useRef<HTMLDivElement>(null);
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Form-internal state — seeded from the tariff prop. Resets via
// `key={tariff.id}` on the parent's render.
const [selectedTariffPeriod, setSelectedTariffPeriod] = useState<TariffPeriod | null>(
tariff.periods[0] || null,
);
const [customDays, setCustomDays] = useState<number>(30);
const [customTrafficGb, setCustomTrafficGb] = useState<number>(50);
const [useCustomDays, setUseCustomDays] = useState(false);
const [useCustomTraffic, setUseCustomTraffic] = useState(false);
const purchaseMutation = useMutation({
mutationFn: () => {
const isDailyTariff =
tariff.is_daily || (tariff.daily_price_kopeks && tariff.daily_price_kopeks > 0);
const days = isDailyTariff
? 1
: useCustomDays
? customDays
: selectedTariffPeriod?.days || 30;
const trafficGb =
useCustomTraffic && tariff.custom_traffic_enabled ? customTrafficGb : undefined;
// Forward the subscription_id when the user landed here via the
// "Renew this subscription" flow (?subscriptionId=N). The backend
// uses it to resolve the exact target row by ID, avoiding the
// race with concurrent panel webhooks that would otherwise hit
// the partial UNIQUE on uq_subscriptions_user_tariff_active.
return subscriptionApi.purchaseTariff(
tariff.id,
days,
trafficGb,
subscriptionId ?? undefined,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
},
});
// Smooth scroll the form into view when first mounted.
useEffect(() => {
if (ref.current) {
const timer = setTimeout(() => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return () => clearTimeout(timer);
}
}, []);
return (
<div ref={ref} className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-dark-100">{tariff.name}</h3>
<button onClick={onBack} className="text-dark-400 hover:text-dark-200">
{t('common.back')}
</button>
</div>
{/* Tariff Info */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-dark-500">{t('subscription.traffic')}:</span>
<span className="ml-2 text-dark-200">{tariff.traffic_limit_label}</span>
</div>
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">
{tariff.device_limit === 0 ? '∞' : tariff.device_limit}
{tariff.extra_devices_count > 0 && (
<span className="ml-1 text-xs text-accent-400">
(+{tariff.extra_devices_count})
</span>
)}
</span>
</div>
</div>
</div>
{/* Daily Tariff Purchase */}
{tariff.is_daily || (tariff.daily_price_kopeks && tariff.daily_price_kopeks > 0) ? (
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 p-5">
<div className="mb-4 text-center">
<div className="mb-2 text-sm text-dark-400">
{t('subscription.dailyPurchase.costPerDay')}
</div>
<div className="text-3xl font-bold text-accent-400">
{formatPrice(tariff.daily_price_kopeks || 0)}
</div>
</div>
<div className="space-y-2 text-sm text-dark-400">
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.chargedDaily')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.canPause')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.pausedOnLowBalance')}</span>
</div>
</div>
{(() => {
const dailyPrice = tariff.daily_price_kopeks || 0;
const hasEnoughBalance = balanceKopeks !== undefined && dailyPrice <= balanceKopeks;
return (
<div className="mt-6">
{balanceKopeks !== undefined && !hasEnoughBalance && (
<InsufficientBalancePrompt
missingAmountKopeks={dailyPrice - balanceKopeks}
compact
className="mb-4"
/>
)}
<button
onClick={() => purchaseMutation.mutate()}
disabled={purchaseMutation.isPending}
className="btn-primary w-full py-3"
>
{purchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.dailyPurchase.activate', {
price: formatPrice(dailyPrice),
})
)}
</button>
{purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(purchaseMutation.error)}
</div>
)}
{purchaseMutation.isError &&
getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3">
<InsufficientBalancePrompt
missingAmountKopeks={
getInsufficientBalanceError(purchaseMutation.error)?.missingAmount ||
dailyPrice - (balanceKopeks || 0)
}
compact
/>
</div>
)}
</div>
);
})()}
</div>
) : (
<>
{/* Period Selection for non-daily tariffs */}
<div>
<div className="mb-3 text-sm text-dark-400">{t('subscription.selectPeriod')}</div>
{tariff.periods.length > 0 && !useCustomDays && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{tariff.periods.map((period) => {
const promoPeriod = applyPromoDiscount(
period.price_kopeks,
period.original_price_kopeks,
);
const displayDiscount = promoPeriod.percent;
const displayOriginal = promoPeriod.original;
const displayPrice = promoPeriod.price;
const displayPerMonth =
displayPrice !== period.price_kopeks
? Math.round(displayPrice / Math.max(1, period.days / 30))
: period.price_per_month_kopeks;
return (
<button
key={period.days}
onClick={() => {
setSelectedTariffPeriod(period);
setUseCustomDays(false);
}}
className={`relative rounded-xl border p-4 text-left transition-all ${
selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
>
{displayDiscount && displayDiscount > 0 && (
<div
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
}`}
>
-{displayDiscount}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(displayPrice)}
</span>
{displayOriginal && displayOriginal > displayPrice && (
<span className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)}
</span>
)}
</div>
<div className="mt-1 text-xs text-dark-500">
{formatPrice(displayPerMonth)}/{t('subscription.month')}
</div>
</button>
);
})}
</div>
)}
{/* No periods available fallback */}
{tariff.periods.length === 0 &&
!useCustomDays &&
!(tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0) && (
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
<div className="mb-2 text-sm font-medium text-warning-400">
{t('subscription.noPeriodsAvailable')}
</div>
<div className="text-xs text-dark-400">
{t('subscription.noPeriodsAvailableHint')}
</div>
<button onClick={onBack} className="btn-secondary mt-3 px-4 py-2 text-sm">
{t('subscription.chooseDifferentTariff')}
</button>
</div>
)}
{/* Custom days option */}
{tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customDays.title')}
</span>
<button
type="button"
onClick={() => setUseCustomDays(!useCustomDays)}
role="switch"
aria-checked={useCustomDays}
aria-label={t('subscription.customDays.title')}
className={`relative h-6 w-10 rounded-full transition-colors ${
useCustomDays ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
useCustomDays ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{useCustomDays && (
<div className="space-y-3">
<div className="flex items-center gap-4">
<input
type="range"
min={tariff.min_days ?? 1}
max={tariff.max_days ?? 365}
value={customDays}
onChange={(e) => setCustomDays(parseInt(e.target.value))}
className="flex-1 accent-accent-500"
/>
<input
type="number"
value={customDays}
min={tariff.min_days ?? 1}
max={tariff.max_days ?? 365}
onChange={(e) =>
setCustomDays(
Math.max(
tariff.min_days ?? 1,
Math.min(
tariff.max_days ?? 365,
parseInt(e.target.value) || (tariff.min_days ?? 1),
),
),
)
}
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
/>
</div>
{(() => {
const basePrice = customDays * (tariff.price_per_day_kopeks ?? 0);
const existingOriginal =
tariff.original_price_per_day_kopeks &&
tariff.original_price_per_day_kopeks > (tariff.price_per_day_kopeks ?? 0)
? customDays * tariff.original_price_per_day_kopeks
: undefined;
const promoCustom = applyPromoDiscount(basePrice, existingOriginal);
return (
<div className="flex justify-between text-sm">
<span className="text-dark-400">
{t('subscription.days', { count: customDays })} ×{' '}
{formatPrice(tariff.price_per_day_kopeks ?? 0)}/
{t('subscription.customDays.perDay')}
</span>
<div className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(promoCustom.price)}
</span>
{promoCustom.original && promoCustom.original > promoCustom.price && (
<>
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoCustom.original)}
</span>
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoCustom.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
-{promoCustom.percent}%
</span>
</>
)}
</div>
</div>
);
})()}
</div>
)}
</div>
)}
</div>
{/* Custom traffic option */}
{tariff.custom_traffic_enabled && (tariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
<div>
<div className="mb-3 text-sm text-dark-400">
{t('subscription.customTraffic.label')}
</div>
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customTraffic.selectVolume')}
</span>
<button
type="button"
onClick={() => setUseCustomTraffic(!useCustomTraffic)}
role="switch"
aria-checked={useCustomTraffic}
aria-label={t('subscription.customTraffic.selectVolume')}
className={`relative h-6 w-10 rounded-full transition-colors ${
useCustomTraffic ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
useCustomTraffic ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{!useCustomTraffic && (
<div className="text-sm text-dark-400">
{t('subscription.customTraffic.default', {
label: tariff.traffic_limit_label,
})}
</div>
)}
{useCustomTraffic && (
<div className="space-y-3">
<div className="flex items-center gap-4">
<input
type="range"
min={tariff.min_traffic_gb ?? 1}
max={tariff.max_traffic_gb ?? 1000}
value={customTrafficGb}
onChange={(e) => setCustomTrafficGb(parseInt(e.target.value))}
className="flex-1 accent-accent-500"
/>
<div className="flex items-center gap-2">
<input
type="number"
value={customTrafficGb}
min={tariff.min_traffic_gb ?? 1}
max={tariff.max_traffic_gb ?? 1000}
onChange={(e) =>
setCustomTrafficGb(
Math.max(
tariff.min_traffic_gb ?? 1,
Math.min(
tariff.max_traffic_gb ?? 1000,
parseInt(e.target.value) || (tariff.min_traffic_gb ?? 1),
),
),
)
}
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
/>
<span className="text-dark-400">{t('common.units.gb')}</span>
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-dark-400">
{customTrafficGb} {t('common.units.gb')} ×{' '}
{formatPrice(tariff.traffic_price_per_gb_kopeks ?? 0)}/
{t('common.units.gb')}
</span>
<span className="font-medium text-accent-400">
+{formatPrice(customTrafficGb * (tariff.traffic_price_per_gb_kopeks ?? 0))}
</span>
</div>
</div>
)}
</div>
</div>
)}
{/* Summary & Purchase */}
{(selectedTariffPeriod || useCustomDays) && (
<div className="rounded-xl bg-dark-800/50 p-5">
{(() => {
const basePeriodPrice = useCustomDays
? customDays * (tariff.price_per_day_kopeks ?? 0)
: selectedTariffPeriod?.price_kopeks || 0;
const existingPeriodOriginal = useCustomDays
? tariff.original_price_per_day_kopeks &&
tariff.original_price_per_day_kopeks > (tariff.price_per_day_kopeks ?? 0)
? customDays * tariff.original_price_per_day_kopeks
: undefined
: selectedTariffPeriod?.original_price_kopeks &&
selectedTariffPeriod.original_price_kopeks > selectedTariffPeriod.price_kopeks
? selectedTariffPeriod.original_price_kopeks
: undefined;
const promoPeriod = applyPromoDiscount(basePeriodPrice, existingPeriodOriginal);
const trafficPrice =
useCustomTraffic && tariff.custom_traffic_enabled
? customTrafficGb * (tariff.traffic_price_per_gb_kopeks ?? 0)
: 0;
const totalPrice = promoPeriod.price + trafficPrice;
const originalTotal = promoPeriod.original
? promoPeriod.original + trafficPrice
: null;
return (
<>
<div className="mb-4 space-y-2">
{useCustomDays ? (
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.stepPeriod')}:{' '}
{t('subscription.days', { count: customDays })}
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
) : (
selectedTariffPeriod && (
<>
{(selectedTariffPeriod.extra_devices_count ?? 0) > 0 &&
selectedTariffPeriod.base_tariff_price_kopeks ? (
<>
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.baseTariff')}: {selectedTariffPeriod.label}
</span>
<span>
{formatPrice(selectedTariffPeriod.base_tariff_price_kopeks)}
</span>
</div>
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.extraDevices')} (
{selectedTariffPeriod.extra_devices_count})
</span>
<span>
+
{formatPrice(
selectedTariffPeriod.extra_devices_cost_kopeks ?? 0,
)}
</span>
</div>
</>
) : (
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.summary.period', {
label: selectedTariffPeriod.label,
})}
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
)}
</>
)
)}
{useCustomTraffic && tariff.custom_traffic_enabled && (
<div className="flex justify-between text-sm text-dark-300">
<span>{t('subscription.summary.traffic', { gb: customTrafficGb })}</span>
<span>+{formatPrice(trafficPrice)}</span>
</div>
)}
</div>
{promoPeriod.percent && (
<div className="mb-4 flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-2">
<span className="text-sm font-medium text-warning-400">
{t('promo.discountApplied')} -{promoPeriod.percent}%
</span>
</div>
)}
<div className="mb-4 flex items-center justify-between border-t border-dark-700/50 pt-2">
<span className="font-medium text-dark-100">{t('subscription.total')}</span>
<div className="text-right">
<span className="text-2xl font-bold text-accent-400">
{formatPrice(totalPrice)}
</span>
{originalTotal && (
<div className="text-sm text-dark-500 line-through">
{formatPrice(originalTotal)}
</div>
)}
</div>
</div>
<button
onClick={() => purchaseMutation.mutate()}
disabled={purchaseMutation.isPending}
className="btn-primary w-full py-3"
>
{purchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.purchase')
)}
</button>
</>
);
})()}
{purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(purchaseMutation.error)}
</div>
)}
{purchaseMutation.isError && getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3">
<InsufficientBalancePrompt
missingAmountKopeks={
getInsufficientBalanceError(purchaseMutation.error)?.missingAmount || 0
}
compact
/>
</div>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { subscriptionApi } from '../../../api/subscription';
import { usePlatform } from '../../../platform';
import { useDestructiveConfirm } from '../../../platform/hooks/useNativeDialog';
// ──────────────────────────────────────────────────────────────────
// Delete-subscription sheet. Telegram path goes through the native
// destructive dialog and skips the inline expand; the web path expands
// an inline confirm panel with two buttons.
//
// Self-owns deleteLoading; parent only supplies the id, theme colour
// hints, open-state, and the onDeleted callback (used for navigate +
// invalidate after the API resolves).
// ──────────────────────────────────────────────────────────────────
export interface DeleteSubscriptionSheetProps {
subscriptionId: number;
open: boolean;
onOpen: () => void;
onClose: () => void;
onDeleted: () => void;
/** color/background tokens already resolved from glassTheme */
textSecondary: string;
}
export function DeleteSubscriptionSheet({
subscriptionId,
open,
onOpen,
onClose,
onDeleted,
textSecondary,
}: DeleteSubscriptionSheetProps) {
const { t } = useTranslation();
const { platform } = usePlatform();
const destructiveConfirm = useDestructiveConfirm();
const [deleteLoading, setDeleteLoading] = useState(false);
const performDelete = async () => {
setDeleteLoading(true);
try {
await subscriptionApi.deleteSubscription(subscriptionId);
onDeleted();
} catch {
setDeleteLoading(false);
onClose();
}
};
const handleTriggerClick = async () => {
if (platform === 'telegram') {
const confirmed = await destructiveConfirm(
t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
),
t('subscription.confirmDelete', 'Да, удалить'),
t('subscription.deleteTitle', 'Удалить подписку?'),
);
if (!confirmed) return;
await performDelete();
} else {
onOpen();
}
};
if (!open) {
return (
<button
onClick={handleTriggerClick}
disabled={deleteLoading}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-error-400/20 bg-error-400/5 p-3.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-400/10 disabled:opacity-50"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')}
</button>
);
}
return (
<div
className="rounded-2xl border border-error-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-error-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={performDelete}
disabled={deleteLoading}
className="flex-1 rounded-xl bg-error-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{deleteLoading
? t('common.processing', 'Удаление...')
: t('subscription.confirmDelete', 'Да, удалить')}
</button>
<button
onClick={onClose}
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
style={{ color: textSecondary }}
>
{t('common.cancel', 'Отмена')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage } from '../../../utils/subscriptionHelpers';
import { ChevronRightIcon } from '../../icons';
// ──────────────────────────────────────────────────────────────────
// Reduce-devices sheet. Self-owns the reduction-info query + mutation;
// parent passes shared state (target limit + setter + ids + open flag).
//
// Extracted from Subscription.tsx to keep one cohesive feature in
// one file (~170 lines off the god page).
// ──────────────────────────────────────────────────────────────────
export interface DeviceReductionSheetProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
subscriptionPresent: boolean;
subscriptionId: number | undefined;
targetDeviceLimit: number;
onTargetDeviceLimitChange: (n: number) => void;
isDark: boolean;
}
export function DeviceReductionSheet({
open,
onOpen,
onClose,
subscriptionPresent,
subscriptionId,
targetDeviceLimit,
onTargetDeviceLimitChange,
isDark,
}: DeviceReductionSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: deviceReductionInfo } = useQuery({
queryKey: ['device-reduction-info', subscriptionId],
queryFn: () => subscriptionApi.getDeviceReductionInfo(subscriptionId),
enabled: open && subscriptionPresent,
});
// Seed the target limit once the info comes back.
useEffect(() => {
if (deviceReductionInfo && open) {
onTargetDeviceLimitChange(
Math.max(
deviceReductionInfo.min_device_limit,
deviceReductionInfo.current_device_limit - 1,
),
);
}
// onTargetDeviceLimitChange is a setter from the parent and stable
// enough; intentionally narrow deps to avoid clobbering user input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deviceReductionInfo, open]);
const reduceMutation = useMutation({
mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['device-reduction-info', subscriptionId] });
onClose();
},
});
if (!open) {
return (
<button
onClick={onOpen}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.reduceDevicesDescription')}
</div>
</div>
<ChevronRightIcon className="text-dark-400" />
</div>
</button>
);
}
return (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevicesTitle')}
</h3>
<button
onClick={onClose}
className="text-sm text-dark-400 hover:text-dark-200"
aria-label={t('common.close', 'Close')}
>
</button>
</div>
{deviceReductionInfo?.available === false ? (
<div className="py-4 text-center text-sm text-dark-400">
{deviceReductionInfo.reason || t('subscription.additionalOptions.reduceUnavailable')}
</div>
) : deviceReductionInfo ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-6">
<button
onClick={() =>
onTargetDeviceLimitChange(
Math.max(
Math.max(
deviceReductionInfo.min_device_limit,
deviceReductionInfo.connected_devices_count,
),
targetDeviceLimit - 1,
),
)
}
disabled={
targetDeviceLimit <=
Math.max(
deviceReductionInfo.min_device_limit,
deviceReductionInfo.connected_devices_count,
)
}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
aria-label={t('subscription.additionalOptions.decrementDevices', 'Уменьшить')}
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">{targetDeviceLimit}</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() =>
onTargetDeviceLimitChange(
Math.min(deviceReductionInfo.current_device_limit - 1, targetDeviceLimit + 1),
)
}
disabled={targetDeviceLimit >= deviceReductionInfo.current_device_limit - 1}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
aria-label={t('subscription.additionalOptions.incrementDevices', 'Увеличить')}
>
+
</button>
</div>
<div className="space-y-1 text-center text-sm text-dark-400">
<div>
{t('subscription.additionalOptions.currentDeviceLimit', {
count: deviceReductionInfo.current_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.minDeviceLimit', {
count: deviceReductionInfo.min_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.connectedDevices', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
</div>
{deviceReductionInfo.connected_devices_count > deviceReductionInfo.min_device_limit && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{t('subscription.additionalOptions.disconnectDevicesFirst', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
)}
<div className="text-center">
<div className="text-sm text-dark-400">
{t('subscription.additionalOptions.newDeviceLimit', {
count: targetDeviceLimit,
})}
</div>
</div>
<button
onClick={() => reduceMutation.mutate()}
disabled={
reduceMutation.isPending ||
targetDeviceLimit >= deviceReductionInfo.current_device_limit ||
targetDeviceLimit < deviceReductionInfo.min_device_limit ||
targetDeviceLimit < deviceReductionInfo.connected_devices_count
}
className="btn-primary w-full py-3"
>
{reduceMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('subscription.additionalOptions.reducing')}
</span>
) : (
t('subscription.additionalOptions.reduce')
)}
</button>
{reduceMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(reduceMutation.error)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-4">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage } from '../../../utils/subscriptionHelpers';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import { ChevronRightIcon } from '../../icons';
import type { PurchaseOptions, Subscription } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// Buy-devices sheet. Self-owns its devicePrice query + purchase mutation;
// parent only passes the subscription / id / open state and the shared
// purchaseOptions (which it already holds for sibling sheets and balance
// gating).
//
// Extracted from Subscription.tsx to drop ~190 lines from the god page.
// ──────────────────────────────────────────────────────────────────
export interface DeviceTopupSheetProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
subscription: Subscription;
subscriptionId: number | undefined;
devicesToAdd: number;
onDevicesToAddChange: (n: number) => void;
purchaseOptions: PurchaseOptions | undefined;
isDark: boolean;
}
export function DeviceTopupSheet({
open,
onOpen,
onClose,
subscription,
subscriptionId,
devicesToAdd,
onDevicesToAddChange,
purchaseOptions,
isDark,
}: DeviceTopupSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const formatPrice = (kopeks: number) => {
const rubles = kopeks / 100;
return rubles % 1 === 0 ? `${rubles}` : `${rubles.toFixed(2)}`;
};
const { data: devicePriceData } = useQuery({
queryKey: ['device-price', devicesToAdd, subscriptionId],
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd, subscriptionId),
enabled: open && !!subscription,
});
const devicePurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['device-price'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
onClose();
onDevicesToAddChange(1);
},
});
if (!open) {
return (
<button
onClick={onOpen}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: subscription.device_limit,
})}
</div>
</div>
<ChevronRightIcon className="text-dark-400" />
</div>
</button>
);
}
return (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">{t('subscription.buyDevices')}</h3>
<button
onClick={onClose}
className="text-sm text-dark-400 hover:text-dark-200"
aria-label={t('common.close', 'Close')}
>
</button>
</div>
{devicePriceData?.available === false ? (
<div className="py-4 text-center text-sm text-dark-400">
{devicePriceData.reason || t('subscription.additionalOptions.devicesUnavailable')}
</div>
) : (
<div className="space-y-4">
{/* Device counter */}
<div className="flex items-center justify-center gap-6">
<button
onClick={() => onDevicesToAddChange(Math.max(1, devicesToAdd - 1))}
disabled={devicesToAdd <= 1}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
aria-label={t('subscription.additionalOptions.decrementDevices', 'Уменьшить')}
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">{devicesToAdd}</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() => onDevicesToAddChange(devicesToAdd + 1)}
disabled={
devicePriceData?.max_device_limit
? (devicePriceData.current_device_limit || 0) + devicesToAdd >=
devicePriceData.max_device_limit
: false
}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
aria-label={t('subscription.additionalOptions.incrementDevices', 'Увеличить')}
>
+
</button>
</div>
{devicePriceData?.max_device_limit && (
<div className="text-center text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: devicePriceData.current_device_limit || subscription.device_limit,
})}{' '}
/{' '}
{t('subscription.additionalOptions.maxDevices', {
count: devicePriceData.max_device_limit,
})}
</div>
)}
{/* Price info */}
{devicePriceData?.available && devicePriceData.price_per_device_label && (
<div className="text-center">
<div className="mb-2 text-sm text-dark-400">
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 &&
devicePriceData.original_price_per_device_kopeks ? (
<span>
<span className="text-dark-500 line-through">
{formatPrice(devicePriceData.original_price_per_device_kopeks)}
</span>
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
</span>
) : (
devicePriceData.price_per_device_label
)}
/{t('subscription.perDevice').replace('/ ', '')} (
{t('subscription.days', { count: devicePriceData.days_left })})
</div>
{devicePriceData.discount_percent && devicePriceData.discount_percent > 0 && (
<div className="mb-2">
<span className="inline-block rounded-full bg-success-500/20 px-2.5 py-0.5 text-sm font-medium text-success-400">
-{devicePriceData.discount_percent}%
</span>
</div>
)}
{devicePriceData.total_price_kopeks === 0 ? (
<div className="text-2xl font-bold text-success-400">
{t('subscription.switchTariff.free')}
</div>
) : (
<div className="text-2xl font-bold text-accent-400">
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 &&
devicePriceData.base_total_price_kopeks && (
<span className="mr-2 text-lg text-dark-500 line-through">
{formatPrice(devicePriceData.base_total_price_kopeks)}
</span>
)}
{devicePriceData.total_price_label}
</div>
)}
</div>
)}
{/* Insufficient balance */}
{devicePriceData?.available &&
purchaseOptions &&
devicePriceData.total_price_kopeks &&
devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
<InsufficientBalancePrompt
missingAmountKopeks={
devicePriceData.total_price_kopeks - purchaseOptions.balance_kopeks
}
compact
onBeforeTopUp={async () => {
await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
}}
/>
)}
<button
onClick={() => devicePurchaseMutation.mutate()}
disabled={
devicePurchaseMutation.isPending ||
!devicePriceData?.available ||
!!(
devicePriceData?.total_price_kopeks &&
purchaseOptions &&
devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks
)
}
className="btn-primary w-full py-3"
>
{devicePurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.additionalOptions.buy')
)}
</button>
{devicePurchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(devicePurchaseMutation.error)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,321 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage, getFlagEmoji } from '../../../utils/subscriptionHelpers';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import { ChevronRightIcon } from '../../icons';
import type { PurchaseOptions, Subscription } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// Manage-servers sheet (classic-mode only — caller decides whether to
// render). Self-owns the countries query + update mutation; parent
// holds the selected-uuid set so the global "close all modals" can
// reset it.
//
// Extracted from Subscription.tsx — ~280 lines off the god page.
// ──────────────────────────────────────────────────────────────────
export interface ServerManagementSheetProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
subscription: Subscription;
subscriptionId: number | undefined;
selectedServers: string[];
onSelectedServersChange: (uuids: string[] | ((prev: string[]) => string[])) => void;
purchaseOptions: PurchaseOptions | undefined;
isDark: boolean;
}
export function ServerManagementSheet({
open,
onOpen,
onClose,
subscription,
subscriptionId,
selectedServers,
onSelectedServersChange,
purchaseOptions,
isDark,
}: ServerManagementSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const formatPrice = (kopeks: number) => {
const rubles = kopeks / 100;
return rubles % 1 === 0 ? `${rubles}` : `${rubles.toFixed(2)}`;
};
const { data: countriesData, isLoading: countriesLoading } = useQuery({
queryKey: ['countries', subscriptionId],
queryFn: () => subscriptionApi.getCountries(subscriptionId),
enabled: open && !!subscription && !subscription.is_trial,
});
// Seed selected = currently connected once the data loads.
useEffect(() => {
if (countriesData && open) {
const connected = countriesData.countries.filter((c) => c.is_connected).map((c) => c.uuid);
onSelectedServersChange(connected);
}
// Intentionally narrow — the setter is stable and we don't want to
// re-seed every time the user toggles a server.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [countriesData, open]);
const updateMutation = useMutation({
mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['countries', subscriptionId] });
onClose();
},
});
if (!open) {
return (
<button
onClick={onOpen}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.manageServers')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.servers', { count: subscription.servers?.length || 0 })}
</div>
</div>
<ChevronRightIcon className="text-dark-400" />
</div>
</button>
);
}
return (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.manageServersTitle')}
</h3>
<button
onClick={() => {
onClose();
onSelectedServersChange([]);
}}
className="text-sm text-dark-400 hover:text-dark-200"
aria-label={t('common.close', 'Close')}
>
</button>
</div>
{countriesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : countriesData && countriesData.countries.length > 0 ? (
<div className="space-y-4">
<div
className={`rounded-lg p-2 text-xs ${isDark ? 'bg-dark-700/30 text-dark-500' : 'bg-champagne-300/40 text-champagne-600'}`}
>
{t('subscription.serverManagement.statusLegend')}
</div>
{countriesData.discount_percent > 0 && (
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-2 text-xs text-success-400">
🎁{' '}
{t('subscription.serverManagement.discountBanner', {
percent: countriesData.discount_percent,
})}
</div>
)}
<div className="max-h-64 space-y-2 overflow-y-auto">
{countriesData.countries
.filter((country) => country.is_available || country.is_connected)
.map((country) => {
const isCurrentlyConnected = country.is_connected;
const isSelected = selectedServers.includes(country.uuid);
const willBeAdded = !isCurrentlyConnected && isSelected;
const willBeRemoved = isCurrentlyConnected && !isSelected;
return (
<button
key={country.uuid}
onClick={() => {
if (isSelected) {
onSelectedServersChange((prev) => prev.filter((u) => u !== country.uuid));
} else {
onSelectedServersChange((prev) => [...prev, country.uuid]);
}
}}
disabled={!country.is_available && !isCurrentlyConnected}
className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
isSelected
? willBeAdded
? 'border-success-500 bg-success-500/10'
: 'border-accent-500 bg-accent-500/10'
: willBeRemoved
? 'border-error-500/50 bg-error-500/5'
: isDark
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">
{willBeAdded ? '' : willBeRemoved ? '' : isSelected ? '✅' : '⚪'}
</span>
<div>
<div className="flex items-center gap-2 font-medium text-dark-100">
{country.name}
{country.has_discount && !isCurrentlyConnected && (
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
-{country.discount_percent}%
</span>
)}
</div>
{willBeAdded && (
<div className="text-xs text-success-400">
+{formatPrice(country.price_kopeks)}{' '}
{t('subscription.serverManagement.forDays', {
days: countriesData.days_left,
})}
{country.has_discount && (
<span className="ml-1 text-dark-500 line-through">
{formatPrice(
Math.round(
(country.base_price_kopeks * countriesData.days_left) / 30,
),
)}
</span>
)}
</div>
)}
{!willBeAdded && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">
{formatPrice(country.price_per_month_kopeks)}
{t('subscription.serverManagement.perMonth')}
{country.has_discount && (
<span className="ml-1 text-dark-600 line-through">
{formatPrice(country.base_price_kopeks)}
</span>
)}
</div>
)}
{!country.is_available && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">
{t('subscription.serverManagement.unavailable')}
</div>
)}
</div>
</div>
{country.country_code && (
<span className="text-xl">{getFlagEmoji(country.country_code)}</span>
)}
</button>
);
})}
</div>
{(() => {
const currentConnected = countriesData.countries
.filter((c) => c.is_connected)
.map((c) => c.uuid);
const added = selectedServers.filter((u) => !currentConnected.includes(u));
const removed = currentConnected.filter((u) => !selectedServers.includes(u));
const hasChanges = added.length > 0 || removed.length > 0;
const addedServers = countriesData.countries.filter((c) => added.includes(c.uuid));
const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0);
const hasEnoughBalance =
!purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
const missingAmount = purchaseOptions ? totalCost - purchaseOptions.balance_kopeks : 0;
return hasChanges ? (
<div
className={`space-y-3 border-t pt-3 ${isDark ? 'border-dark-700/50' : 'border-champagne-300/60'}`}
>
{added.length > 0 && (
<div className="text-sm">
<span className="text-success-400">
{t('subscription.serverManagement.toAdd')}
</span>{' '}
<span className="text-dark-300">
{addedServers.map((s) => s.name).join(', ')}
</span>
</div>
)}
{removed.length > 0 && (
<div className="text-sm">
<span className="text-error-400">
{t('subscription.serverManagement.toDisconnect')}
</span>{' '}
<span className="text-dark-300">
{countriesData.countries
.filter((c) => removed.includes(c.uuid))
.map((s) => s.name)
.join(', ')}
</span>
</div>
)}
{totalCost > 0 && (
<div className="text-center">
<div className="text-sm text-dark-400">
{t('subscription.serverManagement.paymentProrated')}
</div>
<div className="text-xl font-bold text-accent-400">
{formatPrice(totalCost)}
</div>
</div>
)}
{totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
<InsufficientBalancePrompt missingAmountKopeks={missingAmount} compact />
)}
<button
onClick={() => updateMutation.mutate(selectedServers)}
disabled={
updateMutation.isPending ||
selectedServers.length === 0 ||
(totalCost > 0 && !hasEnoughBalance)
}
className="btn-primary w-full py-3"
>
{updateMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.serverManagement.applyChanges')
)}
</button>
</div>
) : (
<div className="py-2 text-center text-sm text-dark-500">
{t('subscription.serverManagement.selectServersHint')}
</div>
);
})()}
{updateMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(updateMutation.error)}
</div>
)}
</div>
) : (
<div className="py-4 text-center text-sm text-dark-400">
{t('subscription.serverManagement.noServersAvailable')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,239 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage } from '../../../utils/subscriptionHelpers';
import { useCurrency } from '../../../hooks/useCurrency';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// SwitchTariffSheet
//
// Inline preview + confirm of a tariff switch on an existing
// subscription. Self-owns:
// - the previewTariffSwitch query
// - the switchTariff mutation
// - the auto-scroll-into-view effect
//
// The parent keeps the `switchTariffId` selection state because the
// trigger lives on a tariff card inside TariffPickerGrid. When the
// backend reports `subscription_expired` (the user's subscription
// lapsed while the modal was open), the sheet hands off to the
// parent via `onExpiredFallback(tariff)`, which opens the regular
// TariffPurchaseForm instead of attempting another switch.
// ──────────────────────────────────────────────────────────────────
export interface SwitchTariffSheetProps {
open: boolean;
tariffId: number | null;
subscriptionId: number | undefined;
tariffs: Tariff[];
onClose: () => void;
onExpiredFallback: (tariff: Tariff) => void;
}
export function SwitchTariffSheet({
open,
tariffId,
subscriptionId,
tariffs,
onClose,
onExpiredFallback,
}: SwitchTariffSheetProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const ref = useRef<HTMLDivElement>(null);
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({
queryKey: ['tariff-switch-preview', tariffId],
queryFn: () => subscriptionApi.previewTariffSwitch(tariffId!, subscriptionId),
enabled: !!tariffId,
});
const switchMutation = useMutation({
mutationFn: (id: number) => subscriptionApi.switchTariff(id, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
onClose();
navigate('/subscriptions', { replace: true });
},
onError: (error: unknown) => {
// Backend signal: the subscription lapsed mid-flight. Hand the
// selected tariff back to the parent so it can open the regular
// purchase form instead.
if (error instanceof AxiosError) {
const detail = error.response?.data?.detail;
if (
typeof detail === 'object' &&
detail?.error_code === 'subscription_expired' &&
detail?.use_purchase_flow === true
) {
const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
if (targetTariff) {
onClose();
onExpiredFallback(targetTariff);
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
}
}
}
},
});
// Smoothly scroll the panel into view when opened.
useEffect(() => {
if (open && ref.current) {
const timer = setTimeout(() => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return () => clearTimeout(timer);
}
}, [open]);
if (!open || !tariffId) return null;
return (
<div ref={ref} className="mb-6 space-y-4 rounded-xl bg-dark-800/50 p-5">
<div className="flex items-center justify-between">
<h3 className="font-medium text-dark-100">{t('subscription.switchTariff.title')}</h3>
<button
onClick={onClose}
className="text-sm text-dark-400 hover:text-dark-200"
aria-label={t('common.close', 'Close')}
>
</button>
</div>
{switchPreviewLoading ? (
<div className="flex items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
switchPreview &&
(() => {
const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
const dailyPrice =
targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0;
const isDailyTariff = dailyPrice > 0;
return (
<>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.currentTariff')}</span>
<span className="font-medium text-dark-100">
{switchPreview.current_tariff_name || '-'}
</span>
</div>
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.newTariff')}</span>
<span className="font-medium text-accent-400">
{switchPreview.new_tariff_name}
</span>
</div>
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.remainingDays')}</span>
<span>{switchPreview.remaining_days}</span>
</div>
</div>
{isDailyTariff && (
<div className="rounded-lg border border-accent-500/30 bg-accent-500/10 p-3 text-center">
<div className="text-sm text-dark-300">
{t('subscription.switchTariff.dailyPayment')}
</div>
<div className="text-lg font-bold text-accent-400">{formatPrice(dailyPrice)}</div>
<div className="mt-1 text-xs text-dark-400">
{t('subscription.switchTariff.dailyChargeDescription')}
</div>
</div>
)}
<div className="flex items-center justify-between border-t border-dark-700/50 pt-3">
<div>
<span className="font-medium text-dark-100">
{t('subscription.switchTariff.upgradeCost')}
</span>
{switchPreview.discount_percent && switchPreview.discount_percent > 0 && (
<span className="ml-2 inline-block rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-400">
-{switchPreview.discount_percent}%
</span>
)}
</div>
<div className="text-right">
{switchPreview.discount_percent &&
switchPreview.discount_percent > 0 &&
switchPreview.base_upgrade_cost_kopeks &&
switchPreview.base_upgrade_cost_kopeks > 0 && (
<span className="mr-2 text-sm text-dark-500 line-through">
{formatPrice(switchPreview.base_upgrade_cost_kopeks)}
</span>
)}
<span
className={`text-lg font-bold ${switchPreview.upgrade_cost_kopeks === 0 ? 'text-success-400' : 'text-accent-400'}`}
>
{switchPreview.upgrade_cost_kopeks > 0
? switchPreview.upgrade_cost_label
: t('subscription.switchTariff.free')}
</span>
</div>
</div>
{!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={switchPreview.missing_amount_kopeks}
compact
/>
)}
<button
onClick={() => switchMutation.mutate(tariffId)}
disabled={switchMutation.isPending || !switchPreview.can_switch}
className="btn-primary w-full py-2.5"
>
{switchMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.switchTariff.switch')
)}
</button>
{switchMutation.isError &&
(() => {
// Suppress the toast when the subscription_expired
// fallback already triggered: the parent is now
// showing the regular purchase form, surfacing the
// raw axios message here would be misleading.
const detail =
switchMutation.error instanceof AxiosError
? switchMutation.error.response?.data?.detail
: null;
if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') {
return null;
}
return (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(switchMutation.error)}
</div>
);
})()}
</>
);
})()
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage } from '../../../utils/subscriptionHelpers';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import { ChevronRightIcon } from '../../icons';
import type { PurchaseOptions, Subscription } from '../../../types';
// ──────────────────────────────────────────────────────────────────
// Buy-traffic sheet. Self-owns the packages query + purchase mutation;
// parent passes the selectedTrafficPackage state (the parent already
// resets it on global "close all modals", which is why it stays up
// top), shared purchaseOptions, and ids/flags.
//
// Extracted from Subscription.tsx — ~170 lines off the god page.
// ──────────────────────────────────────────────────────────────────
export interface TrafficTopupSheetProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
subscription: Subscription;
subscriptionId: number | undefined;
selectedTrafficPackage: number | null;
onSelectedTrafficPackageChange: (gb: number | null) => void;
purchaseOptions: PurchaseOptions | undefined;
isDark: boolean;
}
export function TrafficTopupSheet({
open,
onOpen,
onClose,
subscription,
subscriptionId,
selectedTrafficPackage,
onSelectedTrafficPackageChange,
purchaseOptions,
isDark,
}: TrafficTopupSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const formatPrice = (kopeks: number) => {
const rubles = kopeks / 100;
return rubles % 1 === 0 ? `${rubles}` : `${rubles.toFixed(2)}`;
};
const { data: trafficPackages } = useQuery({
queryKey: ['traffic-packages', subscriptionId],
queryFn: () => subscriptionApi.getTrafficPackages(subscriptionId),
enabled: open && !!subscription,
});
const purchaseMutation = useMutation({
mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['traffic-packages', subscriptionId] });
onClose();
onSelectedTrafficPackageChange(null);
},
});
if (!open) {
return (
<button
onClick={onOpen}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyTraffic')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentTrafficLimit', {
limit: subscription.traffic_limit_gb,
used: subscription.traffic_used_gb.toFixed(1),
})}
</div>
</div>
<ChevronRightIcon className="text-dark-400" />
</div>
</button>
);
}
return (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyTrafficTitle')}
</h3>
<button
onClick={() => {
onClose();
onSelectedTrafficPackageChange(null);
}}
className="text-sm text-dark-400 hover:text-dark-200"
aria-label={t('common.close', 'Close')}
>
</button>
</div>
<div
className={`mb-4 rounded-lg p-2 text-xs ${isDark ? 'bg-dark-700/30 text-dark-500' : 'bg-champagne-300/40 text-champagne-600'}`}
>
{t('subscription.additionalOptions.trafficWarning')}
</div>
{!trafficPackages || trafficPackages.length === 0 ? (
<div className="py-4 text-center text-sm text-dark-400">
{t('subscription.additionalOptions.trafficUnavailable')}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{trafficPackages.map((pkg) => (
<button
key={pkg.gb}
onClick={() => onSelectedTrafficPackageChange(pkg.gb)}
className={`rounded-xl border p-4 text-center transition-all ${
selectedTrafficPackage === pkg.gb
? 'border-accent-500 bg-accent-500/10'
: isDark
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
}`}
>
<div className="text-lg font-semibold text-dark-100">
{pkg.is_unlimited
? '♾️ ' + t('subscription.additionalOptions.unlimited')
: `${pkg.gb} ${t('common.units.gb')}`}
</div>
{pkg.discount_percent && pkg.discount_percent > 0 && (
<div className="mb-1">
<span className="inline-block rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-400">
-{pkg.discount_percent}%
</span>
</div>
)}
<div className="font-medium text-accent-400">
{pkg.discount_percent && pkg.discount_percent > 0 && pkg.base_price_kopeks ? (
<>
<span className="mr-1 text-sm text-dark-500 line-through">
{formatPrice(pkg.base_price_kopeks)}
</span>
{formatPrice(pkg.price_kopeks)}
</>
) : (
formatPrice(pkg.price_kopeks)
)}
</div>
</button>
))}
</div>
{selectedTrafficPackage !== null &&
(() => {
const selectedPkg = trafficPackages.find((p) => p.gb === selectedTrafficPackage);
const hasEnoughBalance =
!selectedPkg ||
!purchaseOptions ||
selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
const missingAmount =
selectedPkg && purchaseOptions
? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
: 0;
return (
<>
{!hasEnoughBalance && missingAmount > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={missingAmount}
compact
className="mb-3"
onBeforeTopUp={async () => {
await subscriptionApi.saveTrafficCart(
selectedTrafficPackage,
subscriptionId,
);
}}
/>
)}
<button
onClick={() => purchaseMutation.mutate(selectedTrafficPackage)}
disabled={purchaseMutation.isPending || !hasEnoughBalance}
className="btn-primary w-full py-3"
>
{purchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : selectedPkg?.is_unlimited ? (
t('subscription.additionalOptions.buyUnlimited')
) : (
t('subscription.additionalOptions.buyTrafficGb', {
gb: selectedTrafficPackage,
})
)}
</button>
</>
);
})()}
{purchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(purchaseMutation.error)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -116,7 +116,7 @@ export function MessageMediaGrid({
loading="lazy"
/>
{isLastVisible && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/60 text-2xl font-semibold text-white">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-dark-950/60 text-2xl font-semibold text-white">
+{hiddenCount}
</div>
)}
@@ -172,7 +172,7 @@ export function MessageMediaGrid({
photoItems[fullscreenIndex] &&
createPortal(
<div
className="fixed inset-0 z-[9999] bg-black"
className="fixed inset-0 z-[9999] bg-dark-950"
style={{ touchAction: 'pan-x pan-y pinch-zoom' }}
>
<button
@@ -225,7 +225,7 @@ export function MessageMediaGrid({
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/70 px-3 py-1 text-sm text-white">
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-dark-950/70 px-3 py-1 text-sm text-white">
{fullscreenIndex + 1} / {photoItems.length}
</div>
</>

View File

@@ -333,7 +333,7 @@ export function Sheet({
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ${
className={`absolute inset-0 bg-dark-950/60 backdrop-blur-sm transition-opacity duration-300 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
/>
@@ -388,5 +388,5 @@ export function Sheet({
// Light theme styles applied via CSS
// Add to globals.css:
// .light .sheet-backdrop { @apply bg-black/40; }
// .light .sheet-backdrop { @apply bg-dark-950/40; }
// .light .sheet-container { @apply bg-champagne-100; }

107
src/hooks/useFocusTrap.ts Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import type { RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
interface FocusTrapOptions {
/** Called when Escape is pressed while the trap is active. */
onEscape?: () => void;
/** Lock body scroll while active. Default: true. Set false if the caller already locks scroll. */
lockScroll?: boolean;
}
/**
* Traps keyboard focus inside a container while `active`.
* - Moves focus into the container on activation, restores it on deactivation.
* - Cycles Tab / Shift+Tab within the container (no escape into the page behind).
* - Optionally closes on Escape and locks body scroll.
*
* Attach the returned ref to the dialog element and give it
* `role="dialog" aria-modal="true" tabIndex={-1}`.
*
* @example
* const dialogRef = useFocusTrap<HTMLDivElement>(isOpen, { onEscape: close });
* return <div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1}>...</div>;
*/
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
active: boolean,
options: FocusTrapOptions = {},
): RefObject<T | null> {
const { lockScroll = true } = options;
const containerRef = useRef<T>(null);
// Keep the latest onEscape without re-running the effect on every render
// (which would otherwise yank focus back to the first element repeatedly).
const onEscapeRef = useRef(options.onEscape);
onEscapeRef.current = options.onEscape;
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const getFocusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el.getClientRects().length > 0,
);
// Move focus into the dialog.
const initial = getFocusable();
(initial[0] ?? container).focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscapeRef.current) {
e.preventDefault();
onEscapeRef.current();
return;
}
if (e.key !== 'Tab') return;
const focusable = getFocusable();
if (focusable.length === 0) {
e.preventDefault();
container.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey) {
if (activeEl === first || !container.contains(activeEl)) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !container.contains(activeEl)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
let prevOverflow: string | undefined;
if (lockScroll) {
prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (lockScroll) {
document.body.style.overflow = prevOverflow ?? '';
}
previouslyFocused?.focus?.();
};
}, [active, lockScroll]);
return containerRef;
}

View File

@@ -0,0 +1,73 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { promoApi } from '../api/promo';
// ──────────────────────────────────────────────────────────────────
// usePromoDiscount
//
// Single source of truth for the active-discount query + the
// applyPromoDiscount helper. Extracted from SubscriptionPurchase.tsx
// so that every flow / sub-component (tariff picker, tariff purchase
// form, classic wizard, switch-tariff sheet) can call the same hook
// without re-fetching or threading a function through props.
//
// Returns:
// activeDiscount: the raw API value (or undefined while loading)
// applyPromoDiscount: combines the active discount with any
// pre-existing price reduction (promo-group pricing) and reports
// final price, original price, total percent off, and whether
// the existing reduction is a promo-group price.
// ──────────────────────────────────────────────────────────────────
export interface PromoDiscountResult {
price: number;
original: number | null;
percent: number | null;
isPromoGroup: boolean;
}
export function usePromoDiscount() {
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
});
const applyPromoDiscount = useCallback(
(priceKopeks: number, existingOriginalPrice?: number | null): PromoDiscountResult => {
const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks;
const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent;
if (!hasExisting && !hasPromo) {
return { price: priceKopeks, original: null, percent: null, isPromoGroup: false };
}
let finalPrice = priceKopeks;
if (hasPromo) {
finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100));
}
if (hasExisting) {
const combinedPercent = hasPromo
? Math.round((1 - finalPrice / existingOriginalPrice!) * 100)
: Math.round((1 - priceKopeks / existingOriginalPrice!) * 100);
return {
price: finalPrice,
original: existingOriginalPrice!,
percent: combinedPercent,
isPromoGroup: true,
};
}
return {
price: finalPrice,
original: priceKopeks,
percent: activeDiscount!.discount_percent!,
isPromoGroup: false,
};
},
[activeDiscount],
);
return { activeDiscount, applyPromoDiscount };
}

View File

@@ -14,6 +14,7 @@ import {
expandViewport,
retrieveLaunchParams,
retrieveRawInitData,
themeParamsState,
} from '@telegram-apps/sdk-react';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
@@ -66,6 +67,46 @@ export function getTelegramInitData(): string | null {
}
}
function isDarkHexColor(hex: string): boolean {
const m = hex.replace('#', '');
const full = m.length === 3 ? m.replace(/(.)/g, '$1$1') : m;
if (full.length !== 6) return false;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
// Perceived sRGB luminance; below 0.5 reads as a dark surface.
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
}
/**
* The Telegram client's effective color scheme ('light' | 'dark'), derived from
* the theme background color. Returns null outside Telegram or before theme params load.
*/
export function getTelegramColorScheme(): 'light' | 'dark' | null {
if (!detectTelegram()) return null;
try {
const bg = themeParamsState()?.bgColor;
return bg ? (isDarkHexColor(bg) ? 'dark' : 'light') : null;
} catch {
return null;
}
}
/**
* The user's Telegram client language as a 2-letter code (e.g. 'en'), or null
* outside Telegram / when unavailable.
*/
export function getTelegramLanguageCode(): string | null {
if (!detectTelegram()) return null;
try {
const user = retrieveLaunchParams().tgWebAppData?.user as { languageCode?: string } | undefined;
const code = user?.languageCode;
return code ? code.split('-')[0].toLowerCase() : null;
} catch {
return null;
}
}
export type TelegramPlatform =
| 'android'
| 'ios'

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
import { STORAGE_KEYS } from '../config/constants';
import { getTelegramColorScheme } from './useTelegramSDK';
type Theme = 'dark' | 'light';
@@ -74,6 +75,13 @@ export function useTheme() {
if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light';
}
// No stored preference: follow the Telegram client's color scheme in a Mini App.
if (!stored) {
const tgScheme = getTelegramColorScheme();
if (tgScheme && enabled[tgScheme]) {
return tgScheme;
}
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light';

View File

@@ -5,8 +5,8 @@ import type { ThemeColors } from '../types/theme';
const FALLBACKS: Record<TrafficColorKey, string> = {
accent: '#3b82f6',
warning: '#FFB800',
error: '#FF3B5C',
warning: 'rgb(var(--color-urgent-400))',
error: 'rgb(var(--color-critical-500))',
};
const COLOR_MAP: Record<TrafficColorKey, keyof ThemeColors> = {

View File

@@ -1,6 +1,7 @@
import i18n, { type ResourceLanguage } from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { getTelegramLanguageCode } from './hooks/useTelegramSDK';
const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
ru: () => import('./locales/ru.json'),
@@ -11,6 +12,7 @@ const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>
const SUPPORTED_LANGS = Object.keys(localeLoaders);
const FALLBACK_LNG = 'ru';
const LANGUAGE_STORAGE_KEY = 'cabinet_language';
const loadedLanguages = new Set<string>();
@@ -55,10 +57,46 @@ const detectedLng = i18n.language?.split('-')[0] || FALLBACK_LNG;
const langsToLoad = [FALLBACK_LNG, ...(detectedLng !== FALLBACK_LNG ? [detectedLng] : [])];
Promise.all(langsToLoad.map(loadLanguage));
// Keep <html lang> + dir in sync with i18n so screen readers pronounce
// content correctly, browsers don't offer to translate it, and RTL
// languages (fa) flip layout direction. index.html ships with lang="ru"
// for the first paint; runtime updates take over from there.
const RTL_LANGS = new Set(['fa', 'ar', 'he', 'ur']);
function syncHtmlLang(lng: string): void {
const code = lng.split('-')[0];
if (typeof document === 'undefined') return;
if (document.documentElement.lang !== code) {
document.documentElement.lang = code;
}
const dir = RTL_LANGS.has(code) ? 'rtl' : 'ltr';
if (document.documentElement.dir !== dir) {
document.documentElement.dir = dir;
}
}
syncHtmlLang(detectedLng);
// Lazy-load on language change
i18n.on('languageChanged', (lng: string) => {
const code = lng.split('-')[0];
loadLanguage(code);
syncHtmlLang(code);
});
/**
* On first run inside Telegram (no explicit stored choice), adopt the user's
* Telegram client language. Must be called after the Telegram SDK is initialised
* (e.g. from main.tsx), since launch params are unavailable before init().
*/
export function applyTelegramLanguage(): void {
try {
if (localStorage.getItem(LANGUAGE_STORAGE_KEY)) return; // explicit choice wins
} catch {
return;
}
const code = getTelegramLanguageCode();
if (code && SUPPORTED_LANGS.includes(code) && i18n.language?.split('-')[0] !== code) {
i18n.changeLanguage(code);
}
}
export default i18n;

View File

@@ -282,7 +282,7 @@
"trialOffer": {
"freeTitle": "Free Trial Available",
"paidTitle": "Trial Subscription",
"freeDesc": "Try our VPN for free no commitment required",
"freeDesc": "Try our VPN for free, no commitment required",
"paidDesc": "Get started with a trial subscription"
},
"stats": {
@@ -1475,7 +1475,7 @@
"description": "Search and manage payments",
"searchPlaceholder": "Search: invoice, TG ID, @username, email...",
"searchHint": "Examples: SP-78291, @username, 123456789, user@mail.ru",
"searchResults": "Results for: {{query}} — found {{count}}",
"searchResults": "Results for: {{query}}. Found {{count}}",
"resetSearch": "Reset",
"statusAll": "All",
"statusPending": "Pending",
@@ -1521,7 +1521,7 @@
"methodEnabled": "Method enabled",
"providerNotConfigured": "Provider not configured in env",
"openUrlDirect": "Open payment page directly",
"openUrlDirectHint": "Skip the link panel the provider opens inside the MiniApp/tab right after the click. After payment the user returns to /balance/top-up/result. Ignored for Stars/CryptoBot and other t.me/ links.",
"openUrlDirectHint": "Skip the link panel: the provider opens inside the MiniApp/tab right after the click. After payment the user returns to /balance/top-up/result. Ignored for Stars/CryptoBot and other t.me/ links.",
"displayName": "Display name",
"displayNameHint": "Leave empty for default name",
"subOptions": "Payment options",
@@ -2151,7 +2151,7 @@
"summaryErrors": "{{count}} errors"
},
"errors": {
"title": "{{count}} errors — show details"
"title": "{{count}} errors. Show details"
},
"filters": {
"search": "Search by ID, username, email",
@@ -2398,7 +2398,7 @@
"confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.",
"confirmDeleteWithSubscriptions": "This tariff has {{count}} active subscriptions. After deletion, users will need to select a new tariff when renewing.",
"deleteSuccess": "Tariff deleted successfully",
"deleteSuccessWithSubscriptions": "Tariff deleted. {{count}} subscriptions reset — users will choose a new tariff on renewal.",
"deleteSuccessWithSubscriptions": "Tariff deleted. {{count}} subscriptions reset. Users will choose a new tariff on renewal.",
"saveOrder": "Save order",
"orderSaved": "Order saved",
"dragToReorder": "Drag to reorder",
@@ -2459,7 +2459,7 @@
"serversTabHint": "Select servers available on this tariff.",
"noServersAvailable": "No servers available",
"promoGroupsTitle": "Promo Groups",
"promoGroupsHint": "Select promo groups that have access to this tariff. If none selected available to everyone.",
"promoGroupsHint": "Select promo groups that have access to this tariff. If none selected, available to everyone.",
"noPromoGroups": "No promo groups",
"extraDeviceTitle": "Additional devices",
"devicePriceLabel": "Device price (30 days):",
@@ -2612,7 +2612,7 @@
"notSelected": "Not selected",
"tariffOption": "{{traffic}} GB, {{devices}} dev.",
"durationDays": "Duration (days)",
"noBonusDescription": "Campaign without bonuses only for tracking clicks and registrations.",
"noBonusDescription": "Campaign without bonuses: only for tracking clicks and registrations.",
"active": "Active",
"cancel": "Cancel",
"save": "Save",
@@ -2662,7 +2662,7 @@
},
"users": {
"title": "Campaign Users",
"campaignRegistrations": "{{name}} {{count}} registrations",
"campaignRegistrations": "{{name}}: {{count}} registrations",
"noUsers": "No registered users",
"user": "User",
"bonus": "Bonus",
@@ -4862,7 +4862,7 @@
"activateButton": "Activate gift",
"activating": "Activating...",
"activateSuccess": "Gift activated!",
"activateSuccessDesc": "{{tariff}} {{days}} days added to your subscription",
"activateSuccessDesc": "{{tariff}}: {{days}} days added to your subscription",
"activateError": "Failed to activate gift",
"activateSelfError": "You cannot activate your own gift",
"myGiftsEmpty": "No gifts yet",

View File

@@ -1511,7 +1511,7 @@
"description": "جستجو و مدیریت پرداخت‌ها",
"searchPlaceholder": "جستجو: فاکتور، شناسه تلگرام، @نام‌کاربری، ایمیل...",
"searchHint": "مثال‌ها: SP-78291, @username, 123456789, user@mail.ru",
"searchResults": "نتایج برای: {{query}} {{count}} یافت شد",
"searchResults": "نتایج برای: {{query}}. {{count}} یافت شد",
"resetSearch": "بازنشانی",
"statusAll": "همه",
"statusPending": "در انتظار",
@@ -1915,7 +1915,7 @@
"confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.",
"confirmDeleteWithSubscriptions": "این تعرفه {{count}} اشتراک فعال دارد. پس از حذف، کاربران هنگام تمدید باید تعرفه جدید انتخاب کنند.",
"deleteSuccess": "تعرفه با موفقیت حذف شد",
"deleteSuccessWithSubscriptions": "تعرفه حذف شد. تعرفه {{count}} اشتراک بازنشانی شد کاربران هنگام تمدید تعرفه جدید انتخاب می‌کنند.",
"deleteSuccessWithSubscriptions": "تعرفه حذف شد. تعرفه {{count}} اشتراک بازنشانی شد. کاربران هنگام تمدید تعرفه جدید انتخاب می‌کنند.",
"saveOrder": "ذخیره ترتیب",
"orderSaved": "ترتیب ذخیره شد",
"dragToReorder": "برای تغییر ترتیب بکشید",
@@ -2088,7 +2088,7 @@
"methodEnabled": "روش فعال است",
"providerNotConfigured": "ارائه‌دهنده در env تنظیم نشده",
"openUrlDirect": "صفحه پرداخت را مستقیماً باز کن",
"openUrlDirectHint": "بدون پنل لینک ارائه‌دهنده داخل MiniApp/تب بلافاصله پس از کلیک باز می‌شود. پس از پرداخت کاربر به /balance/top-up/result بازمی‌گردد. برای Stars/CryptoBot و سایر لینک‌های t.me/ این پرچم نادیده گرفته می‌شود.",
"openUrlDirectHint": "بدون پنل لینک: ارائه‌دهنده داخل MiniApp/تب بلافاصله پس از کلیک باز می‌شود. پس از پرداخت کاربر به /balance/top-up/result بازمی‌گردد. برای Stars/CryptoBot و سایر لینک‌های t.me/ این پرچم نادیده گرفته می‌شود.",
"displayName": "نام نمایشی",
"displayNameHint": "برای نام پیش‌فرض خالی بگذارید",
"subOptions": "گزینه‌های پرداخت",
@@ -2163,7 +2163,7 @@
"notSelected": "انتخاب نشده",
"tariffOption": "{{traffic}} GB، {{devices}} دستگاه",
"durationDays": "مدت (روز)",
"noBonusDescription": "کمپین بدون پاداش فقط برای ردیابی کلیک‌ها و ثبت‌نام‌ها.",
"noBonusDescription": "کمپین بدون پاداش: فقط برای ردیابی کلیک‌ها و ثبت‌نام‌ها.",
"active": "فعال",
"cancel": "لغو",
"save": "ذخیره",
@@ -2211,7 +2211,7 @@
},
"users": {
"title": "کاربران کمپین",
"campaignRegistrations": "{{name}} {{count}} ثبت‌نام",
"campaignRegistrations": "{{name}}: {{count}} ثبت‌نام",
"noUsers": "کاربر ثبت‌نام شده‌ای وجود ندارد",
"user": "کاربر",
"bonus": "پاداش",
@@ -3633,7 +3633,7 @@
"summaryErrors": "{{count}} خطا"
},
"errors": {
"title": "{{count}} خطا نمایش جزئیات"
"title": "{{count}} خطا. نمایش جزئیات"
},
"filters": {
"search": "جستجو بر اساس شناسه، نام کاربری، ایمیل",

View File

@@ -172,7 +172,7 @@
"registerHint": "Для регистрации по email сначала авторизуйтесь через Telegram, затем привяжите email в настройках.",
"telegramRequired": "Требуется авторизация через Telegram",
"telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.",
"telegramReopenHint": "Если проблема повторяется закройте и откройте приложение заново",
"telegramReopenHint": "Если проблема повторяется, закройте и откройте приложение заново",
"telegramNotConfigured": "Telegram бот не настроен",
"telegramUnavailable": "Вход через Telegram временно недоступен",
"authenticating": "Авторизация...",
@@ -295,7 +295,7 @@
"trialOffer": {
"freeTitle": "Бесплатный пробный период",
"paidTitle": "Пробная подписка",
"freeDesc": "Попробуйте наш VPN бесплатно без обязательств",
"freeDesc": "Попробуйте наш VPN бесплатно: без обязательств",
"paidDesc": "Начните с пробной подписки"
},
"stats": {
@@ -1497,7 +1497,7 @@
"description": "Поиск и управление платежами",
"searchPlaceholder": "Поиск: инвойс, TG ID, @username, email...",
"searchHint": "Примеры: SP-78291, @username, 123456789, user@mail.ru",
"searchResults": "Результаты по запросу: {{query}} — найдено {{count}}",
"searchResults": "Результаты по запросу: {{query}}. Найдено {{count}}",
"resetSearch": "Сбросить",
"statusAll": "Все",
"statusPending": "В ожидании",
@@ -1543,7 +1543,7 @@
"methodEnabled": "Метод включён",
"providerNotConfigured": "Провайдер не настроен в env",
"openUrlDirect": "Открывать страницу оплаты сразу",
"openUrlDirectHint": "Без панели со ссылкой провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
"openUrlDirectHint": "Без панели со ссылкой: провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
"displayName": "Отображаемое имя",
"displayNameHint": "Оставьте пустым для имени по умолчанию",
"subOptions": "Варианты оплаты",
@@ -2796,7 +2796,7 @@
"confirmDeleteText": "Это действие нельзя отменить. Тариф будет удалён навсегда.",
"confirmDeleteWithSubscriptions": "У этого тарифа {{count}} активных подписок. При удалении пользователям потребуется выбрать новый тариф при продлении.",
"deleteSuccess": "Тариф успешно удален",
"deleteSuccessWithSubscriptions": "Тариф удален. У {{count}} подписок сброшен тариф — при продлении пользователи выберут новый.",
"deleteSuccessWithSubscriptions": "Тариф удален. У {{count}} подписок сброшен тариф. При продлении пользователи выберут новый.",
"saveOrder": "Сохранить порядок",
"orderSaved": "Порядок сохранён",
"dragToReorder": "Перетащите для изменения порядка",
@@ -2857,7 +2857,7 @@
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
"noServersAvailable": "Нет доступных серверов",
"promoGroupsTitle": "Промо-группы",
"promoGroupsHint": "Выберите промо-группы, которым доступен этот тариф. Если не выбрано ничего доступен всем.",
"promoGroupsHint": "Выберите промо-группы, которым доступен этот тариф. Если не выбрано ничего, доступен всем.",
"noPromoGroups": "Нет промо-групп",
"extraDeviceTitle": "Докупка устройств",
"devicePriceLabel": "Цена за устройство (30 дней):",
@@ -3880,7 +3880,7 @@
"deleteSubscription": {
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.",
"activePaidWarning": "{{count}} из {{total}} выбранных подписок активные платные",
"activePaidWarning": "{{count}} из {{total}} выбранных подписок: активные платные",
"forceDeleteConfirm": "Подтверждаю удаление активных платных подписок"
},
"deleteUser": {
@@ -3917,7 +3917,7 @@
"summaryErrors": "{{count}} ошибок"
},
"errors": {
"title": "{{count}} ошибок — показать детали"
"title": "{{count}} ошибок. Показать детали"
},
"filters": {
"search": "Поиск по ID, нику, email",
@@ -5418,7 +5418,7 @@
"activateButton": "Активировать подарок",
"activating": "Активация...",
"activateSuccess": "Подарок активирован!",
"activateSuccessDesc": "{{tariff}} {{days}} дн. добавлено к вашей подписке",
"activateSuccessDesc": "{{tariff}}: {{days}} дн. добавлено к вашей подписке",
"activateError": "Не удалось активировать подарок",
"activateSelfError": "Нельзя активировать свой собственный подарок",
"myGiftsEmpty": "У вас пока нет подарков",

View File

@@ -1286,7 +1286,7 @@
"methodEnabled": "方法已启用",
"providerNotConfigured": "提供商未在env中配置",
"openUrlDirect": "直接打开支付页面",
"openUrlDirectHint": "跳过链接面板点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
"openUrlDirectHint": "跳过链接面板:点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
"displayName": "显示名称",
"displayNameHint": "留空以使用默认名称",
"subOptions": "支付选项",
@@ -1338,7 +1338,7 @@
"description": "搜索和管理支付",
"searchPlaceholder": "搜索发票、TG ID、@用户名、邮箱...",
"searchHint": "示例SP-78291、@username、123456789、user@mail.ru",
"searchResults": "搜索结果{{query}}找到 {{count}} 条",
"searchResults": "搜索结果:{{query}}找到 {{count}} 条",
"resetSearch": "重置",
"statusAll": "全部",
"statusPending": "待处理",
@@ -1955,7 +1955,7 @@
"confirmDeleteText": "此操作不可撤销。套餐将被永久删除。",
"confirmDeleteWithSubscriptions": "此套餐有 {{count}} 个活跃订阅。删除后,用户续订时需要选择新套餐。",
"deleteSuccess": "套餐删除成功",
"deleteSuccessWithSubscriptions": "套餐已删除。{{count}} 个订阅已重置用户续订时将选择新套餐。",
"deleteSuccessWithSubscriptions": "套餐已删除。{{count}} 个订阅已重置用户续订时将选择新套餐。",
"saveOrder": "保存排序",
"orderSaved": "排序已保存",
"dragToReorder": "拖动以重新排序",
@@ -2162,7 +2162,7 @@
"notSelected": "未选择",
"tariffOption": "{{traffic}} GB{{devices}} 台设备",
"durationDays": "时长(天)",
"noBonusDescription": "无奖励活动——仅用于跟踪点击和注册。",
"noBonusDescription": "无奖励活动:仅用于跟踪点击和注册。",
"active": "启用",
"cancel": "取消",
"save": "保存",
@@ -2210,7 +2210,7 @@
},
"users": {
"title": "活动用户",
"campaignRegistrations": "{{name}}{{count}} 次注册",
"campaignRegistrations": "{{name}}:{{count}} 次注册",
"noUsers": "没有注册用户",
"user": "用户",
"bonus": "奖励",
@@ -3632,7 +3632,7 @@
"summaryErrors": "{{count}} 错误"
},
"errors": {
"title": "{{count}} 个错误显示详情"
"title": "{{count}} 个错误显示详情"
},
"filters": {
"search": "按ID、用户名、邮箱搜索",

View File

@@ -20,11 +20,12 @@ import {
isFullscreen,
} from '@telegram-apps/sdk-react';
import { clearStaleSessionIfNeeded } from './utils/token';
import { useAuthStore } from './store/auth';
import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding';
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
import './i18n';
import { applyTelegramLanguage } from './i18n';
import './styles/globals.css';
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
@@ -55,6 +56,9 @@ if (isTelegramEnv && !alreadyInitialized) {
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
// Adopt the user's Telegram client language on first run (no explicit choice yet).
applyTelegramLanguage();
// Each mount in its own try/catch so one failure doesn't block others.
// mountMiniApp() internally mounts themeParams in SDK v3,
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
@@ -97,6 +101,11 @@ if (isTelegramEnv && !alreadyInitialized) {
clearStaleSessionIfNeeded(null);
}
// Bootstrap auth after the Telegram SDK is initialised so CloudStorage-backed
// refresh-token recovery can run inside initialize() (launch params + CloudStorage
// are only available post-init()).
void useAuthStore.getState().initialize();
if ('requestIdleCallback' in window) {
requestIdleCallback(() => initLogoPreload());
} else {

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform';
@@ -137,11 +138,7 @@ const INITIAL_FILTERS: FiltersState = {
// === Utility functions ===
function translateAction(
action: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any,
): string {
function translateAction(action: string, t: TFunction): string {
return action
.split(',')
.map((perm: string) => {
@@ -189,8 +186,8 @@ interface StatusBadgeProps {
function StatusBadge({ status, label }: StatusBadgeProps) {
const colorMap: Record<string, string> = {
success: 'bg-success-500/20 text-success-400',
denied: 'bg-red-500/20 text-red-400',
error: 'bg-amber-500/20 text-amber-400',
denied: 'bg-error-500/20 text-error-400',
error: 'bg-warning-500/20 text-warning-400',
};
return (
@@ -208,11 +205,11 @@ interface MethodBadgeProps {
function MethodBadge({ method }: MethodBadgeProps) {
const colorMap: Record<string, string> = {
GET: 'bg-blue-500/20 text-blue-400',
GET: 'bg-accent-500/20 text-accent-400',
POST: 'bg-success-500/20 text-success-400',
PUT: 'bg-amber-500/20 text-amber-400',
PATCH: 'bg-amber-500/20 text-amber-400',
DELETE: 'bg-red-500/20 text-red-400',
PUT: 'bg-warning-500/20 text-warning-400',
PATCH: 'bg-warning-500/20 text-warning-400',
DELETE: 'bg-error-500/20 text-error-400',
};
return (

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { AdminBackButton } from '../components/admin/AdminBackButton';
import { useFocusTrap } from '../hooks/useFocusTrap';
import {
banSystemApi,
type BanSystemStatus,
@@ -198,6 +200,9 @@ export default function AdminBanSystem() {
const [stats, setStats] = useState<BanSystemStats | null>(null);
const [users, setUsers] = useState<BanUsersListResponse | null>(null);
const [selectedUser, setSelectedUser] = useState<BanUserDetailResponse | null>(null);
const userDetailRef = useFocusTrap<HTMLDivElement>(selectedUser !== null, {
onEscape: () => setSelectedUser(null),
});
const [punishments, setPunishments] = useState<BanPunishmentsListResponse | null>(null);
const [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
@@ -207,8 +212,6 @@ export default function AdminBanSystem() {
const [report, setReport] = useState<BanReportResponse | null>(null);
const [health, setHealth] = useState<BanHealthResponse | null>(null);
const [reportHours, setReportHours] = useState(24);
const reportHoursRef = useRef(reportHours);
reportHoursRef.current = reportHours;
const [settingLoading, setSettingLoading] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -243,101 +246,143 @@ export default function AdminBanSystem() {
const [searchQuery, setSearchQuery] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const loadStatus = useCallback(async () => {
try {
setLoading(true);
const data = await banSystemApi.getStatus();
setStatus(data);
if (!data.enabled || !data.configured) {
// React Query: status once at mount; each tab fetches lazily via `enabled`.
// Caching means switching tabs returns to cached data instantly (with background revalidate).
const statusQuery = useQuery({
queryKey: ['ban-status'] as const,
queryFn: () => banSystemApi.getStatus(),
});
const isReady = !!(status?.enabled && status?.configured);
const dashboardQuery = useQuery({
queryKey: ['ban-stats'] as const,
queryFn: () => banSystemApi.getStats(),
enabled: isReady && activeTab === 'dashboard',
});
const usersQuery = useQuery({
queryKey: ['ban-users'] as const,
queryFn: () => banSystemApi.getUsers({ limit: 50 }),
enabled: isReady && activeTab === 'users',
});
const punishmentsQuery = useQuery({
queryKey: ['ban-punishments'] as const,
queryFn: () => banSystemApi.getPunishments(),
enabled: isReady && activeTab === 'punishments',
});
const nodesQuery = useQuery({
queryKey: ['ban-nodes'] as const,
queryFn: () => banSystemApi.getNodes(),
enabled: isReady && activeTab === 'nodes',
});
const agentsQuery = useQuery({
queryKey: ['ban-agents'] as const,
queryFn: () => banSystemApi.getAgents(),
enabled: isReady && activeTab === 'agents',
});
const violationsQuery = useQuery({
queryKey: ['ban-violations'] as const,
queryFn: () => banSystemApi.getTrafficViolations(),
enabled: isReady && activeTab === 'violations',
});
const settingsQuery = useQuery({
queryKey: ['ban-settings'] as const,
queryFn: () => banSystemApi.getSettings(),
enabled: isReady && activeTab === 'settings',
});
const trafficQuery = useQuery({
queryKey: ['ban-traffic'] as const,
queryFn: () => banSystemApi.getTraffic(),
enabled: isReady && activeTab === 'traffic',
});
const reportsQuery = useQuery({
queryKey: ['ban-report', reportHours] as const,
queryFn: () => banSystemApi.getReport(reportHours),
enabled: isReady && activeTab === 'reports',
});
const healthQuery = useQuery({
queryKey: ['ban-health'] as const,
queryFn: () => banSystemApi.getHealth(),
enabled: isReady && activeTab === 'health',
});
// Sync query data into the existing state vars so the JSX + handlers stay unchanged
// (handleSearch overrides `users` with search results; useEffect re-syncs on next refetch).
useEffect(() => {
if (statusQuery.data) {
setStatus(statusQuery.data);
if (!statusQuery.data.enabled || !statusQuery.data.configured) {
setError(t('banSystem.notConfigured'));
}
} catch {
setError(t('banSystem.loadError'));
} finally {
setLoading(false);
}
}, [t]);
const loadTabData = useCallback(
async (tab: TabType) => {
try {
setLoading(true);
setError(null);
switch (tab) {
case 'dashboard': {
const statsData = await banSystemApi.getStats();
setStats(statsData);
break;
}
case 'users': {
const usersData = await banSystemApi.getUsers({ limit: 50 });
setUsers(usersData);
break;
}
case 'punishments': {
const punishmentsData = await banSystemApi.getPunishments();
setPunishments(punishmentsData);
break;
}
case 'nodes': {
const nodesData = await banSystemApi.getNodes();
setNodes(nodesData);
break;
}
case 'agents': {
const agentsData = await banSystemApi.getAgents();
setAgents(agentsData);
break;
}
case 'violations': {
const violationsData = await banSystemApi.getTrafficViolations();
setViolations(violationsData);
break;
}
case 'settings': {
const settingsData = await banSystemApi.getSettings();
setSettings(settingsData);
break;
}
case 'traffic': {
const trafficData = await banSystemApi.getTraffic();
setTraffic(trafficData);
break;
}
case 'reports': {
const reportData = await banSystemApi.getReport(reportHoursRef.current);
setReport(reportData);
break;
}
case 'health': {
const healthData = await banSystemApi.getHealth();
setHealth(healthData);
break;
}
}
} catch {
setError(t('banSystem.loadError'));
} finally {
setLoading(false);
}
},
[t],
);
if (statusQuery.isError) setError(t('banSystem.loadError'));
}, [statusQuery.data, statusQuery.isError, t]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
if (dashboardQuery.data) setStats(dashboardQuery.data);
}, [dashboardQuery.data]);
useEffect(() => {
if (status?.enabled && status?.configured) {
loadTabData(activeTab);
}
}, [activeTab, status, loadTabData]);
if (usersQuery.data) setUsers(usersQuery.data);
}, [usersQuery.data]);
useEffect(() => {
if (punishmentsQuery.data) setPunishments(punishmentsQuery.data);
}, [punishmentsQuery.data]);
useEffect(() => {
if (nodesQuery.data) setNodes(nodesQuery.data);
}, [nodesQuery.data]);
useEffect(() => {
if (agentsQuery.data) setAgents(agentsQuery.data);
}, [agentsQuery.data]);
useEffect(() => {
if (violationsQuery.data) setViolations(violationsQuery.data);
}, [violationsQuery.data]);
useEffect(() => {
if (settingsQuery.data) setSettings(settingsQuery.data);
}, [settingsQuery.data]);
useEffect(() => {
if (trafficQuery.data) setTraffic(trafficQuery.data);
}, [trafficQuery.data]);
useEffect(() => {
if (reportsQuery.data) setReport(reportsQuery.data);
}, [reportsQuery.data]);
useEffect(() => {
if (healthQuery.data) setHealth(healthQuery.data);
}, [healthQuery.data]);
// Map activeTab → its query (used for `loading` derivation and refetchActiveTab below).
const activeTabQuery =
activeTab === 'dashboard'
? dashboardQuery
: activeTab === 'users'
? usersQuery
: activeTab === 'punishments'
? punishmentsQuery
: activeTab === 'nodes'
? nodesQuery
: activeTab === 'agents'
? agentsQuery
: activeTab === 'violations'
? violationsQuery
: activeTab === 'settings'
? settingsQuery
: activeTab === 'traffic'
? trafficQuery
: activeTab === 'reports'
? reportsQuery
: healthQuery;
// Derive `loading` from status + active tab query.
useEffect(() => {
setLoading(statusQuery.isLoading || activeTabQuery.isFetching);
if (activeTabQuery.isError) setError(t('banSystem.loadError'));
}, [statusQuery.isLoading, activeTabQuery.isFetching, activeTabQuery.isError, t]);
const refetchActiveTab = useCallback(() => {
void activeTabQuery.refetch();
}, [activeTabQuery]);
const handleSearch = async () => {
if (!searchQuery.trim()) {
loadTabData('users');
void usersQuery.refetch();
return;
}
try {
@@ -367,7 +412,7 @@ export default function AdminBanSystem() {
try {
setActionLoading(userId);
await banSystemApi.unbanUser(userId);
loadTabData('punishments');
void punishmentsQuery.refetch();
} catch {
setError(t('banSystem.loadError'));
} finally {
@@ -379,7 +424,7 @@ export default function AdminBanSystem() {
try {
setSettingLoading(key);
await banSystemApi.toggleSetting(key);
loadTabData('settings');
void settingsQuery.refetch();
} catch {
setError(t('banSystem.loadError'));
} finally {
@@ -391,7 +436,7 @@ export default function AdminBanSystem() {
try {
setSettingLoading(key);
await banSystemApi.setSetting(key, value);
loadTabData('settings');
void settingsQuery.refetch();
} catch {
setError(t('banSystem.loadError'));
} finally {
@@ -403,11 +448,7 @@ export default function AdminBanSystem() {
setReportHours(hours);
};
useEffect(() => {
if (activeTab === 'reports' && status?.enabled) {
loadTabData('reports');
}
}, [reportHours, activeTab, status, loadTabData]);
// (reports query auto-refetches when reportHours changes — it's in the queryKey)
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
@@ -568,7 +609,7 @@ export default function AdminBanSystem() {
</div>
</div>
<button
onClick={() => loadTabData(activeTab)}
onClick={refetchActiveTab}
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-dark-800 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
>
@@ -1494,19 +1535,25 @@ export default function AdminBanSystem() {
{/* User Detail Modal */}
{selectedUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/50 p-4"
onClick={() => setSelectedUser(null)}
>
<div
ref={userDetailRef}
role="dialog"
aria-modal="true"
aria-labelledby="ban-user-detail-title"
tabIndex={-1}
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-dark-700 bg-dark-800"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<h3 className="text-lg font-semibold text-dark-100">
<h3 id="ban-user-detail-title" className="text-lg font-semibold text-dark-100">
{t('banSystem.userDetail.title')}
</h3>
<button
onClick={() => setSelectedUser(null)}
aria-label={t('common.close')}
className="text-dark-400 hover:text-dark-200"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -76,7 +76,7 @@ const EmailIcon = () => (
function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
if (!channel || channel === 'telegram') {
return (
<span className="flex items-center gap-1 rounded-full bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
<span className="flex items-center gap-1 rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
<TelegramIcon />
<span className="hidden sm:inline">Telegram</span>
</span>

File diff suppressed because it is too large Load Diff

View File

@@ -135,10 +135,14 @@ function TariffSelector({
return (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-tariff-select"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.selectTariff')}
</label>
<select
id="campaign-tariff-select"
value={value || ''}
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
className="input"
@@ -320,11 +324,12 @@ export default function AdminCampaignCreate() {
<div className="card space-y-4">
{/* Name */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label htmlFor="campaign-name" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label>
<input
id="campaign-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -341,11 +346,15 @@ export default function AdminCampaignCreate() {
{/* Start Parameter */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-start-param"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label>
<input
id="campaign-start-param"
type="text"
value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
@@ -366,6 +375,9 @@ export default function AdminCampaignCreate() {
<button
type="button"
onClick={() => setIsActive(!isActive)}
role="switch"
aria-checked={isActive}
aria-label={t('admin.campaigns.form.active')}
className={`relative h-6 w-11 rounded-full transition-colors ${
isActive ? 'bg-accent-500' : 'bg-dark-600'
}`}
@@ -381,15 +393,21 @@ export default function AdminCampaignCreate() {
{/* Bonus Type */}
<div className="card space-y-4">
<h2 className="text-lg font-semibold text-dark-100">
<h2 id="bonus-type-label" className="text-lg font-semibold text-dark-100">
{t('admin.campaigns.form.bonusType')}
</h2>
<div className="grid grid-cols-2 gap-3">
<div
className="grid grid-cols-2 gap-3"
role="radiogroup"
aria-labelledby="bonus-type-label"
>
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => (
<button
key={type}
type="button"
role="radio"
aria-checked={bonusType === type}
onClick={() => setBonusType(type)}
className={`rounded-lg border p-4 text-left transition-all ${
bonusType === type
@@ -435,10 +453,14 @@ export default function AdminCampaignCreate() {
<div className="grid grid-cols-3 gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-sub-days"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.days')}
</label>
<input
id="campaign-sub-days"
type="number"
value={subscriptionDays}
onChange={createNumberInputHandler(setSubscriptionDays, 1)}
@@ -447,10 +469,14 @@ export default function AdminCampaignCreate() {
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-sub-traffic"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.trafficGb')}
</label>
<input
id="campaign-sub-traffic"
type="number"
value={subscriptionTraffic}
onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
@@ -459,10 +485,14 @@ export default function AdminCampaignCreate() {
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-sub-devices"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.devices')}
</label>
<input
id="campaign-sub-devices"
type="number"
value={subscriptionDevices}
onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
@@ -487,10 +517,14 @@ export default function AdminCampaignCreate() {
<TariffSelector tariffs={tariffs} value={tariffId} onChange={setTariffId} />
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-tariff-days"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.durationDays')}
</label>
<input
id="campaign-tariff-days"
type="number"
value={tariffDays}
onChange={createNumberInputHandler(setTariffDays, 1)}

View File

@@ -110,10 +110,14 @@ function TariffSelector({
return (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-tariff-select"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.selectTariff')}
</label>
<select
id="campaign-edit-tariff-select"
value={value || ''}
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
className="input"
@@ -144,10 +148,14 @@ function PartnerSelector({
return (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-partner-select"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.partner')}
</label>
<select
id="campaign-edit-partner-select"
value={value || ''}
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
className="input"
@@ -344,11 +352,15 @@ export default function AdminCampaignEdit() {
<div className="card space-y-4">
{/* Name */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-name"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label>
<input
id="campaign-edit-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -365,11 +377,15 @@ export default function AdminCampaignEdit() {
{/* Start Parameter */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-start-param"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label>
<input
id="campaign-edit-start-param"
type="text"
value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
@@ -390,6 +406,9 @@ export default function AdminCampaignEdit() {
<button
type="button"
onClick={() => setIsActive(!isActive)}
role="switch"
aria-checked={isActive}
aria-label={t('admin.campaigns.form.active')}
className={`relative h-6 w-11 rounded-full transition-colors ${
isActive ? 'bg-accent-500' : 'bg-dark-600'
}`}
@@ -410,15 +429,21 @@ export default function AdminCampaignEdit() {
{/* Bonus Type */}
<div className="card space-y-4">
<h2 className="text-lg font-semibold text-dark-100">
<h2 id="bonus-type-edit-label" className="text-lg font-semibold text-dark-100">
{t('admin.campaigns.form.bonusType')}
</h2>
<div className="grid grid-cols-2 gap-3">
<div
className="grid grid-cols-2 gap-3"
role="radiogroup"
aria-labelledby="bonus-type-edit-label"
>
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => (
<button
key={type}
type="button"
role="radio"
aria-checked={bonusType === type}
onClick={() => setBonusType(type)}
className={`rounded-lg border p-4 text-left transition-all ${
bonusType === type
@@ -464,10 +489,14 @@ export default function AdminCampaignEdit() {
<div className="grid grid-cols-3 gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-sub-days"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.days')}
</label>
<input
id="campaign-edit-sub-days"
type="number"
value={subscriptionDays}
onChange={createNumberInputHandler(setSubscriptionDays, 1)}
@@ -476,10 +505,14 @@ export default function AdminCampaignEdit() {
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-sub-traffic"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.trafficGb')}
</label>
<input
id="campaign-edit-sub-traffic"
type="number"
value={subscriptionTraffic}
onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
@@ -488,10 +521,14 @@ export default function AdminCampaignEdit() {
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-sub-devices"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.devices')}
</label>
<input
id="campaign-edit-sub-devices"
type="number"
value={subscriptionDevices}
onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
@@ -516,10 +553,14 @@ export default function AdminCampaignEdit() {
<TariffSelector tariffs={tariffs} value={tariffId} onChange={setTariffId} />
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
<label
htmlFor="campaign-edit-tariff-days"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.campaigns.form.durationDays')}
</label>
<input
id="campaign-edit-tariff-days"
type="number"
value={tariffDays}
onChange={createNumberInputHandler(setTariffDays, 1)}

View File

@@ -6,6 +6,7 @@ import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap';
const PAGE_SIZE = 50;
@@ -69,6 +70,9 @@ export default function AdminCampaigns() {
const { capabilities } = usePlatform();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
onEscape: () => setDeleteConfirm(null),
});
// Queries
const {
@@ -295,8 +299,20 @@ export default function AdminCampaigns() {
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center 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">
<div
className="absolute inset-0 bg-dark-950/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div
ref={deleteDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="campaign-delete-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6"
>
<h3 id="campaign-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.campaigns.confirm.deleteTitle')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.campaigns.confirm.deleteText')}</p>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useHaptic, useNotify } from '../platform';
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
import {
adminChannelsApi,
type RequiredChannel,
@@ -656,8 +657,10 @@ export default function AdminChannelSubscriptions() {
toggleMutation.mutate(id);
};
const handleDelete = (id: number) => {
if (window.confirm(t('admin.channelSubscriptions.deleteConfirm'))) {
const { confirm: confirmDialog } = useNativeDialog();
const handleDelete = async (id: number) => {
if (await confirmDialog(t('admin.channelSubscriptions.deleteConfirm'))) {
deleteMutation.mutate(id);
}
};

Some files were not shown because too many files have changed in this diff Show More