mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
src/App.tsx
67
src/App.tsx
@@ -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>
|
||||
<LazyPage>
|
||||
<PurchaseSuccess />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/:slug"
|
||||
element={
|
||||
<ErrorBoundary level="app">
|
||||
<LazyPage>
|
||||
<QuickPurchase />
|
||||
</LazyPage>
|
||||
</ErrorBoundary>
|
||||
<LazyPage>
|
||||
<QuickPurchase />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/auto-login"
|
||||
element={
|
||||
<ErrorBoundary level="app">
|
||||
<LazyPage>
|
||||
<AutoLogin />
|
||||
</LazyPage>
|
||||
</ErrorBoundary>
|
||||
<LazyPage>
|
||||
<AutoLogin />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -387,11 +388,9 @@ function App() {
|
||||
path="/balance/top-up/result"
|
||||
element={
|
||||
<ProtectedRoute withLayout={false}>
|
||||
<ErrorBoundary level="app">
|
||||
<LazyPage>
|
||||
<TopUpResult />
|
||||
</LazyPage>
|
||||
</ErrorBoundary>
|
||||
<LazyPage>
|
||||
<TopUpResult />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -518,25 +517,21 @@ function App() {
|
||||
<Route
|
||||
path="/gift"
|
||||
element={
|
||||
<ErrorBoundary level="app">
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<GiftSubscription />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
</ErrorBoundary>
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<GiftSubscription />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/gift/result"
|
||||
element={
|
||||
<ErrorBoundary level="app">
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<GiftResult />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
</ErrorBoundary>
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<GiftResult />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,36 +160,19 @@ 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(
|
||||
t('promo.deactivate.confirmDescription', {
|
||||
percent: activeDiscount?.discount_percent || 0,
|
||||
}),
|
||||
);
|
||||
if (confirmed) {
|
||||
deactivateMutation.mutate();
|
||||
}
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
72
src/components/PromptDialogHost.tsx
Normal file
72
src/components/PromptDialogHost.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
712
src/components/admin/bulkActions/ActionModal.tsx
Normal file
712
src/components/admin/bulkActions/ActionModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
47
src/components/admin/bulkActions/DropdownSelect.tsx
Normal file
47
src/components/admin/bulkActions/DropdownSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
304
src/components/admin/bulkActions/FloatingActionBar.tsx
Normal file
304
src/components/admin/bulkActions/FloatingActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/admin/bulkActions/MultiSelectDropdown.tsx
Normal file
164
src/components/admin/bulkActions/MultiSelectDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/components/admin/bulkActions/SubscriptionSubRow.tsx
Normal file
192
src/components/admin/bulkActions/SubscriptionSubRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/admin/bulkActions/actionTargets.ts
Normal file
27
src/components/admin/bulkActions/actionTargets.ts
Normal 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);
|
||||
}
|
||||
36
src/components/admin/trafficUsage/RiskBadge.tsx
Normal file
36
src/components/admin/trafficUsage/RiskBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/admin/trafficUsage/TrafficIcons.tsx
Normal file
164
src/components/admin/trafficUsage/TrafficIcons.tsx
Normal 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>
|
||||
);
|
||||
131
src/components/admin/trafficUsage/filters/CountryFilter.tsx
Normal file
131
src/components/admin/trafficUsage/filters/CountryFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/admin/trafficUsage/filters/NodeFilter.tsx
Normal file
134
src/components/admin/trafficUsage/filters/NodeFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/components/admin/trafficUsage/filters/PeriodSelector.tsx
Normal file
103
src/components/admin/trafficUsage/filters/PeriodSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/components/admin/trafficUsage/filters/StatusFilter.tsx
Normal file
146
src/components/admin/trafficUsage/filters/StatusFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/admin/trafficUsage/filters/TariffFilter.tsx
Normal file
132
src/components/admin/trafficUsage/filters/TariffFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/admin/trafficUsage/trafficUsageHelpers.ts
Normal file
166
src/components/admin/trafficUsage/trafficUsageHelpers.ts
Normal 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();
|
||||
};
|
||||
293
src/components/admin/userDetail/BalanceTab.tsx
Normal file
293
src/components/admin/userDetail/BalanceTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
src/components/admin/userDetail/GiftsTab.tsx
Normal file
301
src/components/admin/userDetail/GiftsTab.tsx
Normal 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">
|
||||
“{gift.gift_message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
540
src/components/admin/userDetail/InfoTab.tsx
Normal file
540
src/components/admin/userDetail/InfoTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
501
src/components/admin/userDetail/ReferralsTab.tsx
Normal file
501
src/components/admin/userDetail/ReferralsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1199
src/components/admin/userDetail/SubscriptionTab.tsx
Normal file
1199
src/components/admin/userDetail/SubscriptionTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
207
src/components/admin/userDetail/SyncTab.tsx
Normal file
207
src/components/admin/userDetail/SyncTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
394
src/components/admin/userDetail/TicketsTab.tsx
Normal file
394
src/components/admin/userDetail/TicketsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
602
src/components/subscription/purchase/ClassicPurchaseWizard.tsx
Normal file
602
src/components/subscription/purchase/ClassicPurchaseWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
347
src/components/subscription/purchase/TariffPickerGrid.tsx
Normal file
347
src/components/subscription/purchase/TariffPickerGrid.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
641
src/components/subscription/purchase/TariffPurchaseForm.tsx
Normal file
641
src/components/subscription/purchase/TariffPurchaseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/subscription/sheets/DeleteSubscriptionSheet.tsx
Normal file
127
src/components/subscription/sheets/DeleteSubscriptionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
src/components/subscription/sheets/DeviceReductionSheet.tsx
Normal file
228
src/components/subscription/sheets/DeviceReductionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/components/subscription/sheets/DeviceTopupSheet.tsx
Normal file
247
src/components/subscription/sheets/DeviceTopupSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
src/components/subscription/sheets/ServerManagementSheet.tsx
Normal file
321
src/components/subscription/sheets/ServerManagementSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/subscription/sheets/SwitchTariffSheet.tsx
Normal file
239
src/components/subscription/sheets/SwitchTariffSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
src/components/subscription/sheets/TrafficTopupSheet.tsx
Normal file
221
src/components/subscription/sheets/TrafficTopupSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
107
src/hooks/useFocusTrap.ts
Normal 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;
|
||||
}
|
||||
73
src/hooks/usePromoDiscount.ts
Normal file
73
src/hooks/usePromoDiscount.ts
Normal 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 };
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
38
src/i18n.ts
38
src/i18n.ts
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "جستجو بر اساس شناسه، نام کاربری، ایمیل",
|
||||
|
||||
@@ -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": "У вас пока нет подарков",
|
||||
|
||||
@@ -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、用户名、邮箱搜索",
|
||||
|
||||
11
src/main.tsx
11
src/main.tsx
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user