mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -27,11 +27,13 @@ import {
|
|||||||
MaintenanceScreen,
|
MaintenanceScreen,
|
||||||
ChannelSubscriptionScreen,
|
ChannelSubscriptionScreen,
|
||||||
BlacklistedScreen,
|
BlacklistedScreen,
|
||||||
|
AccountDeletedScreen,
|
||||||
} from './components/blocking';
|
} from './components/blocking';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||||
import { saveReturnUrl } from './utils/token';
|
import { saveReturnUrl } from './utils/token';
|
||||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||||
|
import { useSiteVerification } from './hooks/useSiteVerification';
|
||||||
// Auth pages - load immediately (small)
|
// Auth pages - load immediately (small)
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import TelegramCallback from './pages/TelegramCallback';
|
import TelegramCallback from './pages/TelegramCallback';
|
||||||
@@ -218,6 +220,10 @@ function BlockingOverlay() {
|
|||||||
return <BlacklistedScreen />;
|
return <BlacklistedScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blockingType === 'account_deleted') {
|
||||||
|
return <AccountDeletedScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +235,9 @@ function LegacySubscriptionRedirect() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useAnalyticsCounters();
|
useAnalyticsCounters();
|
||||||
|
// Pulls site-verification tokens (Antilopay apay-tag etc.) from the bot
|
||||||
|
// backend and injects matching <meta> tags into document.head.
|
||||||
|
useSiteVerification();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -719,7 +719,13 @@ export const adminUsersApi = {
|
|||||||
userId: number,
|
userId: number,
|
||||||
subscriptionId?: number,
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
devices: {
|
||||||
|
hwid: string;
|
||||||
|
platform: string;
|
||||||
|
device_model: string;
|
||||||
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
|
}[];
|
||||||
total: number;
|
total: number;
|
||||||
device_limit: number;
|
device_limit: number;
|
||||||
}> => {
|
}> => {
|
||||||
@@ -729,6 +735,19 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set / clear local device alias on behalf of the user (admin override).
|
||||||
|
renameUserDevice: async (
|
||||||
|
userId: number,
|
||||||
|
hwid: string,
|
||||||
|
name: string | null,
|
||||||
|
): Promise<{ hwid: string; local_name: string | null }> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/cabinet/admin/users/${userId}/devices/${encodeURIComponent(hwid)}/name`,
|
||||||
|
{ name },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Delete single device
|
// Delete single device
|
||||||
deleteUserDevice: async (
|
deleteUserDevice: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ export interface BlacklistedError {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountDeletedError {
|
||||||
|
code: 'account_deleted';
|
||||||
|
message: string;
|
||||||
|
bot_username?: string;
|
||||||
|
telegram_deep_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function isMaintenanceError(
|
export function isMaintenanceError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
|
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
|
||||||
@@ -179,6 +186,14 @@ export function isBlacklistedError(
|
|||||||
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
|
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAccountDeletedError(
|
||||||
|
error: unknown,
|
||||||
|
): error is { response: { status: 403; data: { detail: AccountDeletedError } } } {
|
||||||
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
const err = error as AxiosError<{ detail: AccountDeletedError }>;
|
||||||
|
return err.response?.status === 403 && err.response?.data?.detail?.code === 'account_deleted';
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
@@ -211,6 +226,21 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAccountDeletedError(error)) {
|
||||||
|
const detail = (error.response?.data as { detail: AccountDeletedError }).detail;
|
||||||
|
// Surface the deleted-account screen. The auth flow (initData login)
|
||||||
|
// is allowed to auto-revive; this branch is for token-bearing
|
||||||
|
// sessions where the user is already in the cabinet but their row
|
||||||
|
// got marked DELETED out-of-band, and for password-only logins
|
||||||
|
// that can't be silently revived.
|
||||||
|
useBlockingStore.getState().setAccountDeleted({
|
||||||
|
message: detail.message,
|
||||||
|
bot_username: detail.bot_username,
|
||||||
|
telegram_deep_link: detail.telegram_deep_link,
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
const requestUrl = originalRequest.url || '';
|
const requestUrl = originalRequest.url || '';
|
||||||
|
|
||||||
|
|||||||
16
src/api/siteVerification.ts
Normal file
16
src/api/siteVerification.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface SiteVerification {
|
||||||
|
apay_tag: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, unauthenticated endpoint — payment-provider crawlers (Antilopay)
|
||||||
|
* need to be able to read these values to validate site ownership.
|
||||||
|
*/
|
||||||
|
export const siteVerificationApi = {
|
||||||
|
async get(): Promise<SiteVerification> {
|
||||||
|
const { data } = await apiClient.get<SiteVerification>('/cabinet/public/site-verification');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -264,6 +264,7 @@ export const subscriptionApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
device_model: string;
|
device_model: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
}>;
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
device_limit: number;
|
device_limit: number;
|
||||||
@@ -275,6 +276,27 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a user-local alias for a device. Pass an empty/null `name` to
|
||||||
|
* clear the alias. Returns the final `local_name` after server-side
|
||||||
|
* normalization (trimmed + length-capped).
|
||||||
|
*
|
||||||
|
* Scope is per-(user, hwid), so the same alias appears across all of
|
||||||
|
* the user's subscriptions in multi-tariff mode.
|
||||||
|
*/
|
||||||
|
renameDevice: async (
|
||||||
|
hwid: string,
|
||||||
|
name: string | null,
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{ hwid: string; local_name: string | null }> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`,
|
||||||
|
{ name },
|
||||||
|
withSubId(subscriptionId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
deleteDevice: async (
|
deleteDevice: async (
|
||||||
hwid: string,
|
hwid: string,
|
||||||
subscriptionId?: number,
|
subscriptionId?: number,
|
||||||
|
|||||||
@@ -43,27 +43,52 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
useEffect(() => {
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
const step = steps[currentStep];
|
const step = steps[currentStep];
|
||||||
|
|
||||||
// Find and highlight target element
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const findTarget = () => {
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 6;
|
||||||
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
|
|
||||||
|
setIsVisible(false);
|
||||||
|
setTargetRect(null);
|
||||||
|
|
||||||
|
const tryFind = () => {
|
||||||
|
if (cancelled) return;
|
||||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
setTargetRect(rect);
|
setTargetRect(rect);
|
||||||
|
|
||||||
// Scroll element into view if needed
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
window.setTimeout(() => {
|
||||||
// Delay visibility for smooth animation
|
if (!cancelled) setIsVisible(true);
|
||||||
setTimeout(() => setIsVisible(true), 100);
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
window.setTimeout(tryFind, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLastStep) {
|
||||||
|
onCompleteRef.current();
|
||||||
|
} else {
|
||||||
|
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsVisible(false);
|
const timer = window.setTimeout(tryFind, 300);
|
||||||
const timer = setTimeout(findTarget, 300);
|
return () => {
|
||||||
return () => clearTimeout(timer);
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [step.target]);
|
}, [step.target]);
|
||||||
|
|
||||||
// Recalculate position on resize/scroll
|
// Recalculate position on resize/scroll
|
||||||
@@ -170,13 +195,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
||||||
{/* Spotlight */}
|
{/* Spotlight */}
|
||||||
<div className="onboarding-spotlight" style={getSpotlightStyle()} />
|
<div
|
||||||
|
className="onboarding-spotlight"
|
||||||
|
style={{
|
||||||
|
...getSpotlightStyle(),
|
||||||
|
pointerEvents: isVisible ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
<div
|
<div
|
||||||
ref={tooltipRef}
|
ref={tooltipRef}
|
||||||
className={`onboarding-tooltip tooltip-${step.placement}`}
|
className={`onboarding-tooltip tooltip-${step.placement}`}
|
||||||
style={getTooltipStyle()}
|
style={{
|
||||||
|
...getTooltipStyle(),
|
||||||
|
pointerEvents: isVisible ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="mb-4 flex items-center gap-1.5">
|
<div className="mb-4 flex items-center gap-1.5">
|
||||||
@@ -222,8 +256,8 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Click handler to advance on target click */}
|
{/* Click handler to advance on target click — only when overlay is fully visible */}
|
||||||
{targetRect && (
|
{targetRect && isVisible && (
|
||||||
<div
|
<div
|
||||||
className="absolute cursor-pointer"
|
className="absolute cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
93
src/components/blocking/AccountDeletedScreen.tsx
Normal file
93
src/components/blocking/AccountDeletedScreen.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePlatform } from '@/platform';
|
||||||
|
import { useBlockingStore } from '../../store/blocking';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen block shown when the backend returns
|
||||||
|
* `403 {detail: {code: "account_deleted", ...}}`.
|
||||||
|
*
|
||||||
|
* Triggered for two situations:
|
||||||
|
* * Token-bearing requests where the user row was flipped to DELETED
|
||||||
|
* by the inactivity-cleanup job out-of-band.
|
||||||
|
* * Email/password login of a previously-DELETED account where we
|
||||||
|
* have no Telegram signature to auto-revive on the server side.
|
||||||
|
*
|
||||||
|
* Recovery: pressing /start in the bot triggers the existing revival
|
||||||
|
* flow (handlers/start.py), which flips status back to ACTIVE. The
|
||||||
|
* "Retry" button reloads the SPA so the next request observes the new
|
||||||
|
* status and clears the block.
|
||||||
|
*/
|
||||||
|
export default function AccountDeletedScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { openTelegramLink } = usePlatform();
|
||||||
|
const info = useBlockingStore((state) => state.accountDeletedInfo);
|
||||||
|
|
||||||
|
const deepLink = info?.telegram_deep_link?.trim() || null;
|
||||||
|
// Route through the platform adapter, not raw window.open. Inside the
|
||||||
|
// Telegram WebView, window.open is intercepted by the client and the
|
||||||
|
// new-tab fallback is blocked on most platforms (Android, iOS). The
|
||||||
|
// TelegramAdapter dispatches to the WebApp SDK's openTelegramLink in
|
||||||
|
// Telegram and falls back to window.open in the standalone web build.
|
||||||
|
const handleOpenBot = () => {
|
||||||
|
if (deepLink) {
|
||||||
|
openTelegramLink(deepLink);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
// Reload rather than just clearing the store: we want a fresh
|
||||||
|
// network round-trip against the (hopefully now-revived) row.
|
||||||
|
useBlockingStore.getState().clearBlocking();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 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>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{deepLink && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenBot}
|
||||||
|
className="block w-full rounded-xl bg-blue-600 px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-dark-950"
|
||||||
|
>
|
||||||
|
{t('blocking.accountDeleted.openBot')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{t('blocking.accountDeleted.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-gray-500">{t('blocking.accountDeleted.hint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||||
export { default as BlacklistedScreen } from './BlacklistedScreen';
|
export { default as BlacklistedScreen } from './BlacklistedScreen';
|
||||||
|
export { default as AccountDeletedScreen } from './AccountDeletedScreen';
|
||||||
|
|||||||
8
src/constants/devices.ts
Normal file
8
src/constants/devices.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Maximum length of a user-set local device alias.
|
||||||
|
*
|
||||||
|
* Mirrors `ALIAS_MAX_LENGTH` in the bot's `user_device_aliases` table.
|
||||||
|
* Keep this value in sync with `app/database/crud/user_device_alias.py`
|
||||||
|
* (Python side enforces it at the DB column width AND at the API layer).
|
||||||
|
*/
|
||||||
|
export const DEVICE_ALIAS_MAX_LENGTH = 64;
|
||||||
62
src/hooks/useSiteVerification.ts
Normal file
62
src/hooks/useSiteVerification.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { siteVerificationApi } from '../api/siteVerification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the configured site-verification tokens from the bot backend
|
||||||
|
* and injects the matching `<meta>` tags into `document.head`.
|
||||||
|
*
|
||||||
|
* Used by Antilopay's verification flow (lk.antilopay.com → Проект →
|
||||||
|
* Верификация → Способ 1: мета-тег). Once the merchant copies the
|
||||||
|
* Antilopay-provided value into `ANTILOPAY_APAY_VERIFICATION_TAG`,
|
||||||
|
* the cabinet will start rendering `<meta name="apay-tag" content="...">`
|
||||||
|
* automatically — no rebuild required.
|
||||||
|
*
|
||||||
|
* The hook is no-op on backend errors (verification is a best-effort
|
||||||
|
* concern and must never block the cabinet from rendering).
|
||||||
|
*/
|
||||||
|
export function useSiteVerification(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
siteVerificationApi
|
||||||
|
.get()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (data.apay_tag) {
|
||||||
|
upsertMetaTag('apay-tag', data.apay_tag);
|
||||||
|
} else {
|
||||||
|
removeMetaTag('apay-tag');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silent fail — verification meta is best-effort, doesn't block UI.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMetaTag(name: string, content: string): void {
|
||||||
|
// Security note: `setAttribute('content', value)` stores `value` as a plain
|
||||||
|
// string attribute — the browser does NOT parse it as HTML. Even if a
|
||||||
|
// compromised backend returned `</meta><script>alert(1)</script>`, it would
|
||||||
|
// be stored literally inside `content="..."` and never executed. DO NOT
|
||||||
|
// switch to `innerHTML` / string concatenation into <head> — that would
|
||||||
|
// make this an XSS sink.
|
||||||
|
let tag = document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
||||||
|
if (!tag) {
|
||||||
|
tag = document.createElement('meta');
|
||||||
|
tag.setAttribute('name', name);
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
}
|
||||||
|
tag.setAttribute('content', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMetaTag(name: string): void {
|
||||||
|
const tag = document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
||||||
|
if (tag) {
|
||||||
|
tag.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -468,6 +468,11 @@
|
|||||||
"confirmDeleteAllDevices": "Delete all devices?",
|
"confirmDeleteAllDevices": "Delete all devices?",
|
||||||
"deviceDeleted": "Device deleted",
|
"deviceDeleted": "Device deleted",
|
||||||
"allDevicesDeleted": "All devices deleted",
|
"allDevicesDeleted": "All devices deleted",
|
||||||
|
"renameDevice": "Rename",
|
||||||
|
"renameDeviceSave": "Save",
|
||||||
|
"renameDeviceCancel": "Cancel",
|
||||||
|
"renameDevicePlaceholder": "Device name",
|
||||||
|
"deviceRenamed": "Device name updated",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"connectedAt": "Connected",
|
"connectedAt": "Connected",
|
||||||
@@ -3176,7 +3181,10 @@
|
|||||||
"none": "No connected devices",
|
"none": "No connected devices",
|
||||||
"resetAll": "Reset all",
|
"resetAll": "Reset all",
|
||||||
"deleted": "Device removed",
|
"deleted": "Device removed",
|
||||||
"allDeleted": "All devices reset"
|
"allDeleted": "All devices reset",
|
||||||
|
"rename": "Rename",
|
||||||
|
"renameSave": "Save",
|
||||||
|
"renamed": "Device name updated"
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Referral program",
|
"title": "Referral program",
|
||||||
@@ -4658,6 +4666,13 @@
|
|||||||
"defaultMessage": "Your account has been blocked.",
|
"defaultMessage": "Your account has been blocked.",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"contactSupport": "If you believe this is an error, please contact support."
|
"contactSupport": "If you believe this is an error, please contact support."
|
||||||
|
},
|
||||||
|
"accountDeleted": {
|
||||||
|
"title": "Account deactivated",
|
||||||
|
"description": "Your account was deactivated after a long period of inactivity. To restore access, open the bot in Telegram and send /start.",
|
||||||
|
"openBot": "Open the bot",
|
||||||
|
"retry": "I pressed /start, retry",
|
||||||
|
"hint": "After running /start, return here and tap «Retry»."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
|
|||||||
@@ -454,6 +454,11 @@
|
|||||||
"month": "ماه",
|
"month": "ماه",
|
||||||
"myDevices": "دستگاههای من",
|
"myDevices": "دستگاههای من",
|
||||||
"noDevices": "هیچ دستگاه متصلی نیست",
|
"noDevices": "هیچ دستگاه متصلی نیست",
|
||||||
|
"renameDevice": "تغییر نام",
|
||||||
|
"renameDeviceSave": "ذخیره",
|
||||||
|
"renameDeviceCancel": "لغو",
|
||||||
|
"renameDevicePlaceholder": "نام دستگاه",
|
||||||
|
"deviceRenamed": "نام دستگاه بهروزرسانی شد",
|
||||||
"perExtraDevice": "/ دستگاه اضافی",
|
"perExtraDevice": "/ دستگاه اضافی",
|
||||||
"perMonth": "/ماه",
|
"perMonth": "/ماه",
|
||||||
"purchasedTraffic": "ترافیک خریداری شده",
|
"purchasedTraffic": "ترافیک خریداری شده",
|
||||||
@@ -2667,7 +2672,10 @@
|
|||||||
"none": "دستگاه متصلی وجود ندارد",
|
"none": "دستگاه متصلی وجود ندارد",
|
||||||
"resetAll": "بازنشانی همه",
|
"resetAll": "بازنشانی همه",
|
||||||
"deleted": "دستگاه حذف شد",
|
"deleted": "دستگاه حذف شد",
|
||||||
"allDeleted": "همه دستگاهها بازنشانی شدند"
|
"allDeleted": "همه دستگاهها بازنشانی شدند",
|
||||||
|
"rename": "تغییر نام",
|
||||||
|
"renameSave": "ذخیره",
|
||||||
|
"renamed": "نام دستگاه بهروزرسانی شد"
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "برنامه ارجاع",
|
"title": "برنامه ارجاع",
|
||||||
@@ -4195,6 +4203,13 @@
|
|||||||
"defaultMessage": "حساب شما مسدود شده است.",
|
"defaultMessage": "حساب شما مسدود شده است.",
|
||||||
"reason": "دلیل",
|
"reason": "دلیل",
|
||||||
"contactSupport": "اگر فکر میکنید این اشتباه است، با پشتیبانی تماس بگیرید."
|
"contactSupport": "اگر فکر میکنید این اشتباه است، با پشتیبانی تماس بگیرید."
|
||||||
|
},
|
||||||
|
"accountDeleted": {
|
||||||
|
"title": "حساب غیرفعال شد",
|
||||||
|
"description": "حساب شما به دلیل عدم فعالیت طولانی غیرفعال شده است. برای بازیابی دسترسی، ربات را در تلگرام باز کرده و دستور /start را ارسال کنید.",
|
||||||
|
"openBot": "باز کردن ربات",
|
||||||
|
"retry": "من /start را زدم، دوباره امتحان کن",
|
||||||
|
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
|
|||||||
@@ -492,6 +492,11 @@
|
|||||||
"confirmDeleteAllDevices": "Удалить все устройства?",
|
"confirmDeleteAllDevices": "Удалить все устройства?",
|
||||||
"deviceDeleted": "Устройство удалено",
|
"deviceDeleted": "Устройство удалено",
|
||||||
"allDevicesDeleted": "Все устройства удалены",
|
"allDevicesDeleted": "Все устройства удалены",
|
||||||
|
"renameDevice": "Переименовать",
|
||||||
|
"renameDeviceSave": "Сохранить",
|
||||||
|
"renameDeviceCancel": "Отмена",
|
||||||
|
"renameDevicePlaceholder": "Имя устройства",
|
||||||
|
"deviceRenamed": "Имя устройства обновлено",
|
||||||
"platform": "Платформа",
|
"platform": "Платформа",
|
||||||
"model": "Модель",
|
"model": "Модель",
|
||||||
"connectedAt": "Подключено",
|
"connectedAt": "Подключено",
|
||||||
@@ -3581,7 +3586,10 @@
|
|||||||
"none": "Нет подключённых устройств",
|
"none": "Нет подключённых устройств",
|
||||||
"resetAll": "Сбросить все",
|
"resetAll": "Сбросить все",
|
||||||
"deleted": "Устройство удалено",
|
"deleted": "Устройство удалено",
|
||||||
"allDeleted": "Все устройства сброшены"
|
"allDeleted": "Все устройства сброшены",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"renameSave": "Сохранить",
|
||||||
|
"renamed": "Имя устройства обновлено"
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Реферальная программа",
|
"title": "Реферальная программа",
|
||||||
@@ -5210,6 +5218,13 @@
|
|||||||
"defaultMessage": "Ваш аккаунт заблокирован.",
|
"defaultMessage": "Ваш аккаунт заблокирован.",
|
||||||
"reason": "Причина",
|
"reason": "Причина",
|
||||||
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
|
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||||
|
},
|
||||||
|
"accountDeleted": {
|
||||||
|
"title": "Аккаунт деактивирован",
|
||||||
|
"description": "Ваш аккаунт был деактивирован за длительную неактивность. Чтобы восстановить доступ, откройте бота в Telegram и отправьте команду /start.",
|
||||||
|
"openBot": "Открыть бота",
|
||||||
|
"retry": "Я нажал /start, попробовать снова",
|
||||||
|
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
|
|||||||
@@ -454,6 +454,11 @@
|
|||||||
"month": "月",
|
"month": "月",
|
||||||
"myDevices": "我的设备",
|
"myDevices": "我的设备",
|
||||||
"noDevices": "没有已连接的设备",
|
"noDevices": "没有已连接的设备",
|
||||||
|
"renameDevice": "重命名",
|
||||||
|
"renameDeviceSave": "保存",
|
||||||
|
"renameDeviceCancel": "取消",
|
||||||
|
"renameDevicePlaceholder": "设备名称",
|
||||||
|
"deviceRenamed": "设备名称已更新",
|
||||||
"perExtraDevice": "/ 额外设备",
|
"perExtraDevice": "/ 额外设备",
|
||||||
"perMonth": "/月",
|
"perMonth": "/月",
|
||||||
"purchasedTraffic": "已购流量",
|
"purchasedTraffic": "已购流量",
|
||||||
@@ -2666,7 +2671,10 @@
|
|||||||
"none": "没有已连接的设备",
|
"none": "没有已连接的设备",
|
||||||
"resetAll": "重置全部",
|
"resetAll": "重置全部",
|
||||||
"deleted": "设备已删除",
|
"deleted": "设备已删除",
|
||||||
"allDeleted": "所有设备已重置"
|
"allDeleted": "所有设备已重置",
|
||||||
|
"rename": "重命名",
|
||||||
|
"renameSave": "保存",
|
||||||
|
"renamed": "设备名称已更新"
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "推荐计划",
|
"title": "推荐计划",
|
||||||
@@ -4076,6 +4084,13 @@
|
|||||||
"defaultMessage": "您的帐户已被封锁。",
|
"defaultMessage": "您的帐户已被封锁。",
|
||||||
"reason": "原因",
|
"reason": "原因",
|
||||||
"contactSupport": "如果您认为这是错误,请联系客服。"
|
"contactSupport": "如果您认为这是错误,请联系客服。"
|
||||||
|
},
|
||||||
|
"accountDeleted": {
|
||||||
|
"title": "账户已停用",
|
||||||
|
"description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。",
|
||||||
|
"openBot": "打开机器人",
|
||||||
|
"retry": "我已按 /start,重试",
|
||||||
|
"hint": "运行 /start 后,返回此处并点击「重试」。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banSystem": {
|
"banSystem": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../api/adminRemnawave';
|
} from '../api/adminRemnawave';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { formatUptime } from '../utils/format';
|
import { formatUptime } from '../utils/format';
|
||||||
|
import { getFlagEmoji } from '../utils/subscriptionHelpers';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -46,48 +47,9 @@ const formatBytes = (bytes: number): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCountryFlag = (code: string | null | undefined): string => {
|
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback
|
||||||
if (!code) return '🌍';
|
// сохранён для случая пустого кода (важно для UI-плейсхолдеров).
|
||||||
const codeMap: Record<string, string> = {
|
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍';
|
||||||
RU: '🇷🇺',
|
|
||||||
US: '🇺🇸',
|
|
||||||
DE: '🇩🇪',
|
|
||||||
NL: '🇳🇱',
|
|
||||||
GB: '🇬🇧',
|
|
||||||
UK: '🇬🇧',
|
|
||||||
FR: '🇫🇷',
|
|
||||||
FI: '🇫🇮',
|
|
||||||
SE: '🇸🇪',
|
|
||||||
NO: '🇳🇴',
|
|
||||||
PL: '🇵🇱',
|
|
||||||
TR: '🇹🇷',
|
|
||||||
JP: '🇯🇵',
|
|
||||||
SG: '🇸🇬',
|
|
||||||
HK: '🇭🇰',
|
|
||||||
KR: '🇰🇷',
|
|
||||||
AU: '🇦🇺',
|
|
||||||
CA: '🇨🇦',
|
|
||||||
CH: '🇨🇭',
|
|
||||||
AT: '🇦🇹',
|
|
||||||
IT: '🇮🇹',
|
|
||||||
ES: '🇪🇸',
|
|
||||||
BR: '🇧🇷',
|
|
||||||
IN: '🇮🇳',
|
|
||||||
AE: '🇦🇪',
|
|
||||||
IL: '🇮🇱',
|
|
||||||
KZ: '🇰🇿',
|
|
||||||
UA: '🇺🇦',
|
|
||||||
CZ: '🇨🇿',
|
|
||||||
RO: '🇷🇴',
|
|
||||||
LV: '🇱🇻',
|
|
||||||
LT: '🇱🇹',
|
|
||||||
EE: '🇪🇪',
|
|
||||||
BG: '🇧🇬',
|
|
||||||
HU: '🇭🇺',
|
|
||||||
MD: '🇲🇩',
|
|
||||||
};
|
|
||||||
return codeMap[code.toUpperCase()] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -5,49 +5,12 @@ import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave';
|
|||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons';
|
import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
// Country flags helper
|
import { getFlagEmoji } from '../utils/subscriptionHelpers';
|
||||||
const getCountryFlag = (code: string | null | undefined): string => {
|
|
||||||
if (!code) return '🌍';
|
// Country flag helper. Алгоритмический ISO 3166-1 alpha-2 → regional indicator,
|
||||||
const codeMap: Record<string, string> = {
|
// чтобы не плодить хардкод-словари (исторически у каждого экрана был свой
|
||||||
RU: '🇷🇺',
|
// неполный, и EE/MX/AR не покрывались). При отсутствии кода — глобус.
|
||||||
US: '🇺🇸',
|
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍';
|
||||||
DE: '🇩🇪',
|
|
||||||
NL: '🇳🇱',
|
|
||||||
GB: '🇬🇧',
|
|
||||||
UK: '🇬🇧',
|
|
||||||
FR: '🇫🇷',
|
|
||||||
FI: '🇫🇮',
|
|
||||||
SE: '🇸🇪',
|
|
||||||
NO: '🇳🇴',
|
|
||||||
PL: '🇵🇱',
|
|
||||||
TR: '🇹🇷',
|
|
||||||
JP: '🇯🇵',
|
|
||||||
SG: '🇸🇬',
|
|
||||||
HK: '🇭🇰',
|
|
||||||
KR: '🇰🇷',
|
|
||||||
AU: '🇦🇺',
|
|
||||||
CA: '🇨🇦',
|
|
||||||
CH: '🇨🇭',
|
|
||||||
AT: '🇦🇹',
|
|
||||||
IT: '🇮🇹',
|
|
||||||
ES: '🇪🇸',
|
|
||||||
BR: '🇧🇷',
|
|
||||||
IN: '🇮🇳',
|
|
||||||
AE: '🇦🇪',
|
|
||||||
IL: '🇮🇱',
|
|
||||||
KZ: '🇰🇿',
|
|
||||||
UA: '🇺🇦',
|
|
||||||
CZ: '🇨🇿',
|
|
||||||
RO: '🇷🇴',
|
|
||||||
LV: '🇱🇻',
|
|
||||||
LT: '🇱🇹',
|
|
||||||
EE: '🇪🇪',
|
|
||||||
BG: '🇧🇬',
|
|
||||||
HU: '🇭🇺',
|
|
||||||
MD: '🇲🇩',
|
|
||||||
};
|
|
||||||
return codeMap[code.toUpperCase()] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminRemnawaveSquadDetail() {
|
export default function AdminRemnawaveSquadDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -7,39 +7,7 @@ import { AdminBackButton } from '../components/admin';
|
|||||||
import { ServerIcon } from '../components/icons';
|
import { ServerIcon } from '../components/icons';
|
||||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
|
import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers';
|
||||||
// Country flags (simple emoji mapping)
|
|
||||||
const getCountryFlag = (code: string | null): string => {
|
|
||||||
if (!code) return '';
|
|
||||||
const codeMap: Record<string, string> = {
|
|
||||||
RU: '🇷🇺',
|
|
||||||
US: '🇺🇸',
|
|
||||||
DE: '🇩🇪',
|
|
||||||
NL: '🇳🇱',
|
|
||||||
GB: '🇬🇧',
|
|
||||||
FR: '🇫🇷',
|
|
||||||
FI: '🇫🇮',
|
|
||||||
SE: '🇸🇪',
|
|
||||||
PL: '🇵🇱',
|
|
||||||
CZ: '🇨🇿',
|
|
||||||
AT: '🇦🇹',
|
|
||||||
CH: '🇨🇭',
|
|
||||||
UA: '🇺🇦',
|
|
||||||
KZ: '🇰🇿',
|
|
||||||
JP: '🇯🇵',
|
|
||||||
KR: '🇰🇷',
|
|
||||||
SG: '🇸🇬',
|
|
||||||
HK: '🇭🇰',
|
|
||||||
CA: '🇨🇦',
|
|
||||||
AU: '🇦🇺',
|
|
||||||
BR: '🇧🇷',
|
|
||||||
IN: '🇮🇳',
|
|
||||||
TR: '🇹🇷',
|
|
||||||
IL: '🇮🇱',
|
|
||||||
AE: '🇦🇪',
|
|
||||||
};
|
|
||||||
return codeMap[code.toUpperCase()] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminServerEdit() {
|
export default function AdminServerEdit() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -20,37 +20,7 @@ const BackIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Country flags (simple emoji mapping)
|
// Country flags (simple emoji mapping)
|
||||||
const getCountryFlag = (code: string | null): string => {
|
import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers';
|
||||||
if (!code) return '';
|
|
||||||
const codeMap: Record<string, string> = {
|
|
||||||
RU: '🇷🇺',
|
|
||||||
US: '🇺🇸',
|
|
||||||
DE: '🇩🇪',
|
|
||||||
NL: '🇳🇱',
|
|
||||||
GB: '🇬🇧',
|
|
||||||
FR: '🇫🇷',
|
|
||||||
FI: '🇫🇮',
|
|
||||||
SE: '🇸🇪',
|
|
||||||
PL: '🇵🇱',
|
|
||||||
CZ: '🇨🇿',
|
|
||||||
AT: '🇦🇹',
|
|
||||||
CH: '🇨🇭',
|
|
||||||
UA: '🇺🇦',
|
|
||||||
KZ: '🇰🇿',
|
|
||||||
JP: '🇯🇵',
|
|
||||||
KR: '🇰🇷',
|
|
||||||
SG: '🇸🇬',
|
|
||||||
HK: '🇭🇰',
|
|
||||||
CA: '🇨🇦',
|
|
||||||
AU: '🇦🇺',
|
|
||||||
BR: '🇧🇷',
|
|
||||||
IN: '🇮🇳',
|
|
||||||
TR: '🇹🇷',
|
|
||||||
IL: '🇮🇱',
|
|
||||||
AE: '🇦🇪',
|
|
||||||
};
|
|
||||||
return codeMap[code.toUpperCase()] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminServers() {
|
export default function AdminServers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type TrafficEnrichmentData,
|
type TrafficEnrichmentData,
|
||||||
} from '../api/adminTraffic';
|
} from '../api/adminTraffic';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { getFlagEmoji as _sharedGetFlagEmoji } from '../utils/subscriptionHelpers';
|
||||||
|
|
||||||
// ============ TanStack Table module augmentation ============
|
// ============ TanStack Table module augmentation ============
|
||||||
|
|
||||||
@@ -40,14 +41,9 @@ const formatBytes = (bytes: number): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFlagEmoji = (countryCode: string): string => {
|
// Локальная обёртка над общим helper'ом, чтобы внутренние сигнатуры (string)
|
||||||
if (!countryCode || countryCode.length !== 2) return '';
|
// оставались как были и call-sites не меняли.
|
||||||
const codePoints = countryCode
|
const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode);
|
||||||
.toUpperCase()
|
|
||||||
.split('')
|
|
||||||
.map((char) => 127397 + char.charCodeAt(0));
|
|
||||||
return String.fromCodePoint(...codePoints);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (kopeks: number): string => {
|
const formatCurrency = (kopeks: number): string => {
|
||||||
const rubles = kopeks / 100;
|
const rubles = kopeks / 100;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useNotify } from '../platform/hooks/useNotify';
|
import { useNotify } from '../platform/hooks/useNotify';
|
||||||
import {
|
import {
|
||||||
@@ -24,51 +25,14 @@ import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
|||||||
import { usePermissionStore } from '../store/permissions';
|
import { usePermissionStore } from '../store/permissions';
|
||||||
import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid';
|
import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid';
|
||||||
import { linkifyText } from '../utils/linkify';
|
import { linkifyText } from '../utils/linkify';
|
||||||
|
import { getFlagEmoji } from '../utils/subscriptionHelpers';
|
||||||
|
|
||||||
// ============ Helpers ============
|
// ============ Helpers ============
|
||||||
|
|
||||||
const getCountryFlag = (code: string | null | undefined): string => {
|
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator (вместо хардкод-словаря,
|
||||||
if (!code) return '';
|
// который не покрывал все страны: например, EE раньше плыл сырым текстом в одних
|
||||||
const codeMap: Record<string, string> = {
|
// местах, MX/AR/EG до сих пор отсутствуют в других). Единая точка истины в utils.
|
||||||
RU: '\u{1F1F7}\u{1F1FA}',
|
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code);
|
||||||
US: '\u{1F1FA}\u{1F1F8}',
|
|
||||||
DE: '\u{1F1E9}\u{1F1EA}',
|
|
||||||
NL: '\u{1F1F3}\u{1F1F1}',
|
|
||||||
GB: '\u{1F1EC}\u{1F1E7}',
|
|
||||||
UK: '\u{1F1EC}\u{1F1E7}',
|
|
||||||
FR: '\u{1F1EB}\u{1F1F7}',
|
|
||||||
FI: '\u{1F1EB}\u{1F1EE}',
|
|
||||||
SE: '\u{1F1F8}\u{1F1EA}',
|
|
||||||
NO: '\u{1F1F3}\u{1F1F4}',
|
|
||||||
PL: '\u{1F1F5}\u{1F1F1}',
|
|
||||||
TR: '\u{1F1F9}\u{1F1F7}',
|
|
||||||
JP: '\u{1F1EF}\u{1F1F5}',
|
|
||||||
SG: '\u{1F1F8}\u{1F1EC}',
|
|
||||||
HK: '\u{1F1ED}\u{1F1F0}',
|
|
||||||
KR: '\u{1F1F0}\u{1F1F7}',
|
|
||||||
AU: '\u{1F1E6}\u{1F1FA}',
|
|
||||||
CA: '\u{1F1E8}\u{1F1E6}',
|
|
||||||
CH: '\u{1F1E8}\u{1F1ED}',
|
|
||||||
AT: '\u{1F1E6}\u{1F1F9}',
|
|
||||||
IT: '\u{1F1EE}\u{1F1F9}',
|
|
||||||
ES: '\u{1F1EA}\u{1F1F8}',
|
|
||||||
BR: '\u{1F1E7}\u{1F1F7}',
|
|
||||||
IN: '\u{1F1EE}\u{1F1F3}',
|
|
||||||
AE: '\u{1F1E6}\u{1F1EA}',
|
|
||||||
IL: '\u{1F1EE}\u{1F1F1}',
|
|
||||||
KZ: '\u{1F1F0}\u{1F1FF}',
|
|
||||||
UA: '\u{1F1FA}\u{1F1E6}',
|
|
||||||
CZ: '\u{1F1E8}\u{1F1FF}',
|
|
||||||
RO: '\u{1F1F7}\u{1F1F4}',
|
|
||||||
LV: '\u{1F1F1}\u{1F1FB}',
|
|
||||||
LT: '\u{1F1F1}\u{1F1F9}',
|
|
||||||
EE: '\u{1F1EA}\u{1F1EA}',
|
|
||||||
BG: '\u{1F1E7}\u{1F1EC}',
|
|
||||||
HU: '\u{1F1ED}\u{1F1FA}',
|
|
||||||
MD: '\u{1F1F2}\u{1F1E9}',
|
|
||||||
};
|
|
||||||
return codeMap[code.toUpperCase()] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ Icons ============
|
// ============ Icons ============
|
||||||
|
|
||||||
@@ -375,11 +339,20 @@ export default function AdminUserDetail() {
|
|||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
const [devices, setDevices] = useState<
|
const [devices, setDevices] = useState<
|
||||||
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
|
{
|
||||||
|
hwid: string;
|
||||||
|
platform: string;
|
||||||
|
device_model: string;
|
||||||
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
|
}[]
|
||||||
>([]);
|
>([]);
|
||||||
const [devicesTotal, setDevicesTotal] = useState(0);
|
const [devicesTotal, setDevicesTotal] = useState(0);
|
||||||
const [deviceLimit, setDeviceLimit] = useState(0);
|
const [deviceLimit, setDeviceLimit] = useState(0);
|
||||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
|
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||||||
|
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false);
|
||||||
|
|
||||||
// Gifts
|
// Gifts
|
||||||
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
||||||
@@ -797,6 +770,29 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Admin renames a device on behalf of the user. Empty/whitespace input
|
||||||
|
// clears the alias and falls back to the platform/model default.
|
||||||
|
const handleRenameDevice = async (hwid: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setRenameSaving(true);
|
||||||
|
// Snapshot inputs BEFORE the await so a fast click on another device
|
||||||
|
// mid-flight doesn't smuggle a different alias into this hwid's request.
|
||||||
|
const snapshotName = editingDeviceName.trim();
|
||||||
|
try {
|
||||||
|
await adminUsersApi.renameUserDevice(userId, hwid, snapshotName || null);
|
||||||
|
notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено'));
|
||||||
|
// Reset edit state only if user is still on the saved row.
|
||||||
|
setEditingDeviceHwid((current) => (current === hwid ? null : current));
|
||||||
|
await loadDevices();
|
||||||
|
} catch (err) {
|
||||||
|
const apiMessage = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||||
|
?.detail;
|
||||||
|
notify.error(apiMessage || t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setRenameSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleResetDevices = async () => {
|
const handleResetDevices = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -2464,44 +2460,183 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : devices.length > 0 ? (
|
) : devices.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{devices.map((device) => (
|
{devices.map((device) => {
|
||||||
<div
|
const isEditing = editingDeviceHwid === device.hwid;
|
||||||
key={device.hwid}
|
// Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix.
|
||||||
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
const displayName =
|
||||||
>
|
(device.local_name && device.local_name.trim()) ||
|
||||||
<div className="min-w-0 flex-1">
|
device.platform ||
|
||||||
<div className="truncate text-xs font-medium text-dark-200">
|
device.device_model ||
|
||||||
{device.platform || device.device_model || device.hwid.slice(0, 12)}
|
device.hwid.slice(0, 12);
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-dark-500">
|
return (
|
||||||
{device.device_model && device.platform && (
|
<div
|
||||||
<span>{device.device_model}</span>
|
key={device.hwid}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingDeviceName}
|
||||||
|
maxLength={DEVICE_ALIAS_MAX_LENGTH}
|
||||||
|
placeholder={
|
||||||
|
device.platform ||
|
||||||
|
device.device_model ||
|
||||||
|
device.hwid.slice(0, 12)
|
||||||
|
}
|
||||||
|
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRenameDevice(device.hwid);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-xs font-medium text-dark-200">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-dark-500">
|
||||||
{device.created_at && (
|
{device.device_model && device.platform && (
|
||||||
<span>
|
<span>{device.device_model}</span>
|
||||||
{new Date(device.created_at).toLocaleDateString(locale)}
|
)}
|
||||||
</span>
|
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
||||||
|
{device.created_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(device.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRenameDevice(device.hwid)}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="rounded-lg px-2 py-1 text-success-400 transition-all hover:bg-success-500/15 disabled:opacity-50"
|
||||||
|
title={t(
|
||||||
|
'admin.users.detail.devices.renameSave',
|
||||||
|
t(
|
||||||
|
'common.save',
|
||||||
|
'\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C',
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
aria-label={t(
|
||||||
|
'admin.users.detail.devices.renameSave',
|
||||||
|
'\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="rounded-lg px-2 py-1 text-dark-500 transition-all hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
title={t(
|
||||||
|
'common.cancel',
|
||||||
|
'\u041E\u0442\u043C\u0435\u043D\u0430',
|
||||||
|
)}
|
||||||
|
aria-label={t(
|
||||||
|
'common.cancel',
|
||||||
|
'\u041E\u0442\u043C\u0435\u043D\u0430',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(device.hwid);
|
||||||
|
setEditingDeviceName(device.local_name || '');
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-2 py-1 text-dark-500 transition-all hover:bg-accent-500/15 hover:text-accent-400"
|
||||||
|
title={t(
|
||||||
|
'admin.users.detail.devices.rename',
|
||||||
|
'\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C',
|
||||||
|
)}
|
||||||
|
aria-label={t(
|
||||||
|
'admin.users.detail.devices.rename',
|
||||||
|
'\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
||||||
|
handleDeleteDevice(device.hwid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? '?'
|
||||||
|
: '\u00D7'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
onClick={() =>
|
})}
|
||||||
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
|
||||||
handleDeleteDevice(device.hwid),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
|
||||||
confirmingAction === `deleteDevice_${device.hwid}`
|
|
||||||
? 'bg-error-500 text-white'
|
|
||||||
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2 text-center text-xs text-dark-500">
|
<div className="py-2 text-center text-xs text-dark-500">
|
||||||
|
|||||||
@@ -246,8 +246,8 @@ export default function Dashboard() {
|
|||||||
}, [t, subscription]);
|
}, [t, subscription]);
|
||||||
|
|
||||||
const handleOnboardingComplete = () => {
|
const handleOnboardingComplete = () => {
|
||||||
setShowOnboarding(false);
|
|
||||||
completeOnboarding();
|
completeOnboarding();
|
||||||
|
setShowOnboarding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Navigate, useNavigate, useParams } from 'react-router';
|
import { Navigate, useNavigate, useParams } from 'react-router';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
|
import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices';
|
||||||
import { WebBackButton } from '../components/WebBackButton';
|
import { WebBackButton } from '../components/WebBackButton';
|
||||||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||||
import { usePlatform } from '../platform';
|
import { usePlatform } from '../platform';
|
||||||
@@ -308,6 +309,30 @@ export default function Subscription() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Local device alias (rename) state. Only one device can be in edit-mode
|
||||||
|
// at a time — `editingDeviceHwid` doubles as both the toggle and the
|
||||||
|
// identifier of the row being edited.
|
||||||
|
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||||||
|
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||||||
|
|
||||||
|
const renameDeviceMutation = useMutation({
|
||||||
|
mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) =>
|
||||||
|
subscriptionApi.renameDevice(hwid, name, subscriptionId),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
|
// Soft success-tap, like other mutations on this page.
|
||||||
|
haptic.notification('success');
|
||||||
|
// Не сбрасываем edit-state, если пользователь уже перешёл на другой
|
||||||
|
// девайс пока шёл запрос — иначе теряем его новый input. Имя не чистим
|
||||||
|
// безусловно: оно либо принадлежит уже другому девайсу (нужно сохранить),
|
||||||
|
// либо инпут уже закрылся (значение не отображается).
|
||||||
|
setEditingDeviceHwid((current) => (current === variables.hwid ? null : current));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
haptic.notification('error');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Pause subscription mutation
|
// Pause subscription mutation
|
||||||
const pauseMutation = useMutation({
|
const pauseMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
||||||
@@ -493,6 +518,9 @@ export default function Subscription() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] });
|
queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
|
// RemnaWave resets device HWIDs on revoke — make sure the cabinet
|
||||||
|
// re-reads the now-empty device list instead of showing the stale cache.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
haptic.notification('success');
|
haptic.notification('success');
|
||||||
localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString());
|
localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString());
|
||||||
setRevokeCooldown(900);
|
setRevokeCooldown(900);
|
||||||
@@ -2403,73 +2431,203 @@ export default function Subscription() {
|
|||||||
? `${devicesData.total} · ∞`
|
? `${devicesData.total} · ∞`
|
||||||
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
||||||
</div>
|
</div>
|
||||||
{devicesData.devices.map((device) => (
|
{devicesData.devices.map((device) => {
|
||||||
<div
|
const isEditing = editingDeviceHwid === device.hwid;
|
||||||
key={device.hwid}
|
// Display priority: user alias → device model → platform.
|
||||||
className="flex items-center justify-between rounded-[12px] p-3.5"
|
const displayName =
|
||||||
style={{
|
(device.local_name && device.local_name.trim()) ||
|
||||||
background: g.innerBg,
|
device.device_model ||
|
||||||
border: `1px solid ${g.innerBorder}`,
|
device.platform;
|
||||||
}}
|
|
||||||
>
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
<div
|
key={device.hwid}
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-[10px]"
|
className="flex items-center justify-between rounded-[12px] p-3.5"
|
||||||
style={{ background: g.trackBg }}
|
style={{
|
||||||
>
|
background: g.innerBg,
|
||||||
<svg
|
border: `1px solid ${g.innerBorder}`,
|
||||||
width="16"
|
}}
|
||||||
height="16"
|
>
|
||||||
viewBox="0 0 24 24"
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
fill="none"
|
<div
|
||||||
stroke={g.textSecondary}
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||||||
strokeWidth="1.5"
|
style={{ background: g.trackBg }}
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path 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
|
||||||
</svg>
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={g.textSecondary}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path 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>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingDeviceName}
|
||||||
|
maxLength={DEVICE_ALIAS_MAX_LENGTH}
|
||||||
|
placeholder={device.device_model || device.platform}
|
||||||
|
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = editingDeviceName.trim();
|
||||||
|
renameDeviceMutation.mutate({
|
||||||
|
hwid: device.hwid,
|
||||||
|
name: trimmed || null,
|
||||||
|
});
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1"
|
||||||
|
style={{
|
||||||
|
background: g.trackBg,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${g.innerBorder}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-sm font-semibold text-dark-50">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
||||||
|
<span className="truncate">{device.platform}</span>
|
||||||
|
<span className="font-mono text-dark-50/20">
|
||||||
|
{device.hwid.slice(0, 8).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
<div className="text-sm font-semibold text-dark-50">
|
{isEditing ? (
|
||||||
{device.device_model || device.platform}
|
<>
|
||||||
</div>
|
<button
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
type="button"
|
||||||
<span>{device.platform}</span>
|
onClick={() => {
|
||||||
<span className="font-mono text-dark-50/20">
|
const trimmed = editingDeviceName.trim();
|
||||||
{device.hwid.slice(0, 8).toUpperCase()}
|
renameDeviceMutation.mutate({
|
||||||
</span>
|
hwid: device.hwid,
|
||||||
</div>
|
name: trimmed || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={renameDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textSecondary }}
|
||||||
|
title={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||||
|
aria-label={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}}
|
||||||
|
disabled={renameDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||||
|
aria-label={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(device.hwid);
|
||||||
|
setEditingDeviceName(device.local_name || '');
|
||||||
|
}}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.renameDevice', 'Переименовать')}
|
||||||
|
aria-label={t('subscription.renameDevice', 'Переименовать')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
||||||
|
deleteDeviceMutation.mutate(device.hwid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.deleteDevice')}
|
||||||
|
aria-label={t('subscription.deleteDevice')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path 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>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
onClick={() => {
|
})}
|
||||||
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
|
||||||
deleteDeviceMutation.mutate(device.hwid);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteDeviceMutation.isPending}
|
|
||||||
className="p-2 transition-colors"
|
|
||||||
style={{ color: g.textFaint }}
|
|
||||||
title={t('subscription.deleteDevice')}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path 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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-[12px] text-dark-50/25">
|
<div className="py-8 text-center text-[12px] text-dark-50/25">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
export type BlockingType = 'maintenance' | 'channel_subscription' | 'blacklisted' | null;
|
export type BlockingType =
|
||||||
|
| 'maintenance'
|
||||||
|
| 'channel_subscription'
|
||||||
|
| 'blacklisted'
|
||||||
|
| 'account_deleted'
|
||||||
|
| null;
|
||||||
|
|
||||||
interface MaintenanceInfo {
|
interface MaintenanceInfo {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -29,15 +34,26 @@ interface BlacklistedInfo {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccountDeletedInfo {
|
||||||
|
/** Backend-provided localized message. We may override with i18n key on render. */
|
||||||
|
message: string;
|
||||||
|
/** Bot username (without @) for building the Telegram deep link client-side as fallback. */
|
||||||
|
bot_username?: string;
|
||||||
|
/** Full Telegram deep-link URL (`https://t.me/<bot>?start=revive`). Empty when bot is unconfigured. */
|
||||||
|
telegram_deep_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BlockingState {
|
interface BlockingState {
|
||||||
blockingType: BlockingType;
|
blockingType: BlockingType;
|
||||||
maintenanceInfo: MaintenanceInfo | null;
|
maintenanceInfo: MaintenanceInfo | null;
|
||||||
channelInfo: ChannelSubscriptionInfo | null;
|
channelInfo: ChannelSubscriptionInfo | null;
|
||||||
blacklistedInfo: BlacklistedInfo | null;
|
blacklistedInfo: BlacklistedInfo | null;
|
||||||
|
accountDeletedInfo: AccountDeletedInfo | null;
|
||||||
|
|
||||||
setMaintenance: (info: MaintenanceInfo) => void;
|
setMaintenance: (info: MaintenanceInfo) => void;
|
||||||
setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
|
setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
|
||||||
setBlacklisted: (info: BlacklistedInfo) => void;
|
setBlacklisted: (info: BlacklistedInfo) => void;
|
||||||
|
setAccountDeleted: (info: AccountDeletedInfo) => void;
|
||||||
clearBlocking: () => void;
|
clearBlocking: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +62,7 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
|||||||
maintenanceInfo: null,
|
maintenanceInfo: null,
|
||||||
channelInfo: null,
|
channelInfo: null,
|
||||||
blacklistedInfo: null,
|
blacklistedInfo: null,
|
||||||
|
accountDeletedInfo: null,
|
||||||
|
|
||||||
setMaintenance: (info) =>
|
setMaintenance: (info) =>
|
||||||
set({
|
set({
|
||||||
@@ -53,6 +70,7 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
|||||||
maintenanceInfo: info,
|
maintenanceInfo: info,
|
||||||
channelInfo: null,
|
channelInfo: null,
|
||||||
blacklistedInfo: null,
|
blacklistedInfo: null,
|
||||||
|
accountDeletedInfo: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setChannelSubscription: (info) =>
|
setChannelSubscription: (info) =>
|
||||||
@@ -61,6 +79,7 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
|||||||
channelInfo: info,
|
channelInfo: info,
|
||||||
maintenanceInfo: null,
|
maintenanceInfo: null,
|
||||||
blacklistedInfo: null,
|
blacklistedInfo: null,
|
||||||
|
accountDeletedInfo: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setBlacklisted: (info) =>
|
setBlacklisted: (info) =>
|
||||||
@@ -69,6 +88,16 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
|||||||
blacklistedInfo: info,
|
blacklistedInfo: info,
|
||||||
maintenanceInfo: null,
|
maintenanceInfo: null,
|
||||||
channelInfo: null,
|
channelInfo: null,
|
||||||
|
accountDeletedInfo: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setAccountDeleted: (info) =>
|
||||||
|
set({
|
||||||
|
blockingType: 'account_deleted',
|
||||||
|
accountDeletedInfo: info,
|
||||||
|
maintenanceInfo: null,
|
||||||
|
channelInfo: null,
|
||||||
|
blacklistedInfo: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
clearBlocking: () =>
|
clearBlocking: () =>
|
||||||
@@ -77,5 +106,6 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
|||||||
maintenanceInfo: null,
|
maintenanceInfo: null,
|
||||||
channelInfo: null,
|
channelInfo: null,
|
||||||
blacklistedInfo: null,
|
blacklistedInfo: null,
|
||||||
|
accountDeletedInfo: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ export interface Device {
|
|||||||
platform: string;
|
platform: string;
|
||||||
device_model: string;
|
device_model: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
/**
|
||||||
|
* User-set local alias persisted in the bot DB (`user_device_aliases`).
|
||||||
|
* `null` when the user hasn't renamed the device — clients fall back
|
||||||
|
* to `device_model` / `platform` for display.
|
||||||
|
*/
|
||||||
|
local_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DevicesResponse {
|
export interface DevicesResponse {
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ export const getInsufficientBalanceError = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFlagEmoji = (countryCode: string): string => {
|
export const getFlagEmoji = (countryCode: string | null | undefined): string => {
|
||||||
if (!countryCode || countryCode.length !== 2) return '';
|
// Trim + длина строго 2 буквы — иначе Unicode regional indicators не дадут флаг.
|
||||||
const codePoints = countryCode
|
// Принимаем null/undefined чтобы вызывающие коду не приходилось страховаться.
|
||||||
|
const code = (countryCode ?? '').trim();
|
||||||
|
if (code.length !== 2 || !/^[A-Za-z]{2}$/.test(code)) return '';
|
||||||
|
const codePoints = code
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.split('')
|
.split('')
|
||||||
.map((char) => 127397 + char.charCodeAt(0));
|
.map((char) => 127397 + char.charCodeAt(0));
|
||||||
|
|||||||
Reference in New Issue
Block a user