Files
bedolaga-cabinet/src/pages/AdminRemnawaveSquadDetail.tsx
Fringg f301d44f24 fix(admin): show flag for every country code, not just hardcoded 25-36
User reported the EE (Estonia) flag rendered as plain text 'EE' on the
admin servers page. Root cause: getCountryFlag() in AdminServers.tsx
and AdminServerEdit.tsx hardcoded a 25-entry codeMap that didn't
include EE; falling through to 'return code' produced raw text.

Two other admin pages (AdminRemnawaveSquadDetail, AdminUserDetail)
had bigger 35-entry maps that did include EE — so EE worked there
but MX/AR/EG/ZA and friends still wouldn't. AdminRemnawave repeated
the same 35-entry map. AdminTrafficUsage already had the correct
algorithmic ISO→regional-indicator code but as a local duplicate of
utils/subscriptionHelpers.

Unify all six on the single algorithmic helper:

- getFlagEmoji() in utils/subscriptionHelpers.ts now:
  * accepts string | null | undefined (callers don't need to guard)
  * trims whitespace
  * validates [A-Za-z]{2} before composing regional indicators
- Each admin page now either imports getFlagEmoji directly or wraps
  it with the page's preferred fallback character (e.g. '🌍' for
  empty codes where the UI expects a placeholder).
2026-05-16 01:11:00 +03:00

255 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave';
import { AdminBackButton } from '../components/admin';
import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons';
import Twemoji from 'react-twemoji';
import { getFlagEmoji } from '../utils/subscriptionHelpers';
// Country flag helper. Алгоритмический ISO 3166-1 alpha-2 → regional indicator,
// чтобы не плодить хардкод-словари (исторически у каждого экрана был свой
// неполный, и EE/MX/AR не покрывались). При отсутствии кода — глобус.
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍';
export default function AdminRemnawaveSquadDetail() {
const { t } = useTranslation();
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
// Fetch all squads and find the one we need
const {
data: squadsData,
isLoading,
error,
} = useQuery({
queryKey: ['admin-remnawave-squads'],
queryFn: adminRemnawaveApi.getSquads,
});
const squad: SquadWithLocalInfo | undefined = squadsData?.items?.find(
(s: SquadWithLocalInfo) => s.uuid === uuid,
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
if (error || !squad) {
return (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/remnawave" />
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.remnawave.squads.detail', 'Squad Details')}
</h1>
</div>
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
<p className="text-error-400">
{t('admin.remnawave.squads.loadError', 'Failed to load squad')}
</p>
<button
onClick={() => navigate('/admin/remnawave')}
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
>
{t('common.back', 'Back')}
</button>
</div>
</div>
);
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/remnawave" />
<div className="flex items-center gap-3">
<span className="text-2xl">{getCountryFlag(squad.country_code)}</span>
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ServerIcon />
</div>
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
{squad.display_name || squad.name}
</Twemoji>
</h1>
<p className="text-sm text-dark-400">{squad.name}</p>
</div>
{squad.is_synced ? (
<span className="rounded-full bg-success-500/20 px-3 py-1 text-xs text-success-400">
{t('admin.remnawave.squads.synced', 'Synced')}
</span>
) : (
<span className="rounded-full bg-warning-500/20 px-3 py-1 text-xs text-warning-400">
{t('admin.remnawave.squads.notSynced', 'Not synced')}
</span>
)}
</div>
{/* Main Info */}
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.remnawave.squads.info', 'Information')}
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div>
<p className="text-sm text-dark-500">UUID</p>
<p className="break-all font-mono text-xs text-dark-200">{squad.uuid}</p>
</div>
<div>
<p className="text-sm text-dark-500">
{t('admin.remnawave.squads.originalName', 'Original Name')}
</p>
<p className="text-dark-200">{squad.name}</p>
</div>
<div>
<p className="text-sm text-dark-500">
{t('admin.remnawave.squads.countryCode', 'Country')}
</p>
<p className="text-dark-200">
{getCountryFlag(squad.country_code)} {squad.country_code || '—'}
</p>
</div>
</div>
</div>
{/* Stats */}
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.remnawave.squads.statsTitle', 'Statistics')}
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<UsersIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.members', 'Members')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.members_count}</p>
</div>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<ServerIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.inbounds', 'Inbounds')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.inbounds_count}</p>
</div>
{squad.is_synced && (
<>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<UsersIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.users', 'Users')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">
{squad.current_users ?? 0}
<span className="text-sm font-normal text-dark-400">
{' '}
/ {squad.max_users ?? '∞'}
</span>
</p>
</div>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<span className="text-sm">{t('admin.remnawave.squads.price', 'Price')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">
{((squad.price_kopeks ?? 0) / 100).toFixed(0)}
</p>
</div>
</>
)}
</div>
</div>
{/* Local Settings (if synced) */}
{squad.is_synced && (
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.remnawave.squads.localSettings', 'Local Settings')}
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-3 rounded-lg bg-dark-700/50 p-4">
<div
className={`rounded-lg p-2 ${
squad.is_available
? 'bg-success-500/20 text-success-400'
: 'bg-error-500/20 text-error-400'
}`}
>
{squad.is_available ? <CheckIcon /> : <XIcon />}
</div>
<div>
<p className="text-sm text-dark-400">
{t('admin.remnawave.squads.available', 'Available')}
</p>
<p className={squad.is_available ? 'text-success-400' : 'text-error-400'}>
{squad.is_available ? t('common.yes', 'Yes') : t('common.no', 'No')}
</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg bg-dark-700/50 p-4">
<div
className={`rounded-lg p-2 ${
squad.is_trial_eligible
? 'bg-success-500/20 text-success-400'
: 'bg-dark-600 text-dark-400'
}`}
>
{squad.is_trial_eligible ? <CheckIcon /> : <XIcon />}
</div>
<div>
<p className="text-sm text-dark-400">
{t('admin.remnawave.squads.trialEligible', 'Trial Eligible')}
</p>
<p className={squad.is_trial_eligible ? 'text-success-400' : 'text-dark-400'}>
{squad.is_trial_eligible ? t('common.yes', 'Yes') : t('common.no', 'No')}
</p>
</div>
</div>
</div>
</div>
)}
{/* Inbounds */}
{squad.inbounds.length > 0 && (
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.remnawave.squads.inboundsList', 'Inbounds')}
</h3>
<div className="space-y-2">
{squad.inbounds.map((inbound: Record<string, unknown>, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-4 py-3"
>
<span className="text-sm text-dark-200">
{String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)}
</span>
{typeof inbound.type === 'string' && (
<span className="rounded bg-dark-600 px-2 py-1 text-xs text-dark-400">
{inbound.type}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end">
<button onClick={() => navigate('/admin/remnawave')} className="btn-secondary">
{t('common.back', 'Back')}
</button>
</div>
</div>
);
}