From 3b48abbb7be4bff486b6e22d832ede314507a3dd Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sat, 6 Jun 2026 21:27:57 +0300 Subject: [PATCH 01/40] refactor(cabinet): unify all statistics onto the shared StatCard Bring every stat strip across the admin panel and user cabinet onto the canonical StatCard (icon chip + label-on-top + tone-coloured value), matching the sales/Remnawave style the rest of the app already used. - Convert ~20 hand-rolled stat strips across 21 pages/components - Remove 4 divergent local StatCard components (Dashboard, Users, BanSystem, Payments) and migrate Remnawave's local StatCard (33 cards) - Extend StatCard with optional subValue, loading skeleton, trailing slot, and icon-size normalisation (all backward-compatible) - StatsGrid nav tiles now use StatCard while keeping link/chevron/loading - Preserve all data, i18n keys, conditionals and interactive filters; fix the dead 'info' colour by mapping it to accent --- src/components/admin/userDetail/GiftsTab.tsx | 27 +- .../admin/userDetail/ReferralsTab.tsx | 64 ++-- src/components/dashboard/StatsGrid.tsx | 142 ++------- src/components/stats/StatCard.tsx | 32 +- src/pages/AdminBanSystem.tsx | 124 ++++---- src/pages/AdminBroadcastDetail.tsx | 45 ++- src/pages/AdminCampaigns.tsx | 53 ++-- src/pages/AdminDashboard.tsx | 276 +++++++----------- src/pages/AdminPartnerDetail.tsx | 117 ++++---- src/pages/AdminPartners.tsx | 52 ++-- src/pages/AdminPayments.tsx | 112 +++---- src/pages/AdminPolicies.tsx | 49 ++-- src/pages/AdminPromoGroups.tsx | 45 +-- src/pages/AdminPromocodeStats.tsx | 48 +-- src/pages/AdminPromocodes.tsx | 53 ++-- src/pages/AdminRemnawave.tsx | 108 +++---- src/pages/AdminRemnawaveSquadDetail.tsx | 63 ++-- src/pages/AdminRoles.tsx | 45 +-- src/pages/AdminTickets.tsx | 62 ++-- src/pages/AdminUsers.tsx | 58 ++-- src/pages/AdminWithdrawals.tsx | 29 +- src/pages/Referral.tsx | 154 +++++----- .../components/NetworkStats.tsx | 78 ++--- 23 files changed, 863 insertions(+), 973 deletions(-) diff --git a/src/components/admin/userDetail/GiftsTab.tsx b/src/components/admin/userDetail/GiftsTab.tsx index b053aff..b64ed45 100644 --- a/src/components/admin/userDetail/GiftsTab.tsx +++ b/src/components/admin/userDetail/GiftsTab.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; -import { SendIcon } from '@/components/icons'; +import { GiftIcon, SendIcon } from '@/components/icons'; +import { StatCard } from '@/components/stats'; import { useCurrency } from '../../../hooks/useCurrency'; import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers'; @@ -208,18 +209,18 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }:
{/* Summary counters */}
-
-
- {t('admin.users.detail.gifts.totalSent')} -
-
{giftsData.sent_total}
-
-
-
- {t('admin.users.detail.gifts.totalReceived')} -
-
{giftsData.received_total}
-
+ } + tone="accent" + /> + } + tone="success" + />
{/* Sent Gifts */} diff --git a/src/components/admin/userDetail/ReferralsTab.tsx b/src/components/admin/userDetail/ReferralsTab.tsx index e64c801..964bc4b 100644 --- a/src/components/admin/userDetail/ReferralsTab.tsx +++ b/src/components/admin/userDetail/ReferralsTab.tsx @@ -5,7 +5,8 @@ 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'; -import { XIcon } from '@/components/icons'; +import { StatCard } from '@/components/stats'; +import { BanknotesIcon, PercentIcon, TagIcon, UsersIcon, XIcon } from '@/components/icons'; // ────────────────────────────────────────────────────────────────── // Referrals tab — top-of-graph referrer + stats + referrals list, @@ -327,40 +328,35 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps) {/* Section 2: Referral stats */}
-
-
- {t('admin.users.detail.referrals.totalReferrals')} -
-
- {user.referral.referrals_count} -
-
-
-
- {t('admin.users.detail.referrals.totalEarnings')} -
-
- {formatWithCurrency(user.referral.total_earnings_kopeks / 100)} -
-
-
-
- {t('admin.users.detail.referrals.commission')} -
-
- {user.referral.commission_percent != null + } + tone="neutral" + /> + } + tone="neutral" + /> + -
-
-
- {t('admin.users.detail.referrals.referralCode')} -
-
- {user.referral.referral_code} -
-
+ : t('admin.users.detail.referrals.default') + } + icon={} + tone="neutral" + /> + } + tone="neutral" + valueClassName="font-mono" + />
{/* Section 3: Referrals list */} diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 3a41585..7701566 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -1,9 +1,8 @@ -import { PiCaretRight } from 'react-icons/pi'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; import { useCurrency } from '../../hooks/useCurrency'; -import { useTheme } from '../../hooks/useTheme'; -import { getGlassColors } from '../../utils/glassTheme'; +import { StatCard } from '@/components/stats'; +import { CardIcon, ChevronRightIcon, UsersIcon } from '@/components/icons'; interface StatsGridProps { balanceRubles: number; @@ -12,10 +11,6 @@ interface StatsGridProps { refLoading: boolean; } -const ChevronIcon = ({ color }: { color: string }) => ( -
@@ -835,19 +795,19 @@ function OverviewTab({ label={t('admin.remnawave.overview.lifetimeTraffic', 'Трафик за всё время')} value={formatBytes(recap.total.traffic_bytes)} icon={} - color="purple" + tone="accent" /> } - color="blue" + tone="accent" /> } - color="green" + tone="success" /> } - color="accent" + tone="accent" />
@@ -936,26 +896,26 @@ function OverviewTab({ label={t('admin.remnawave.overview.panelRam', 'RAM процесса')} value={formatBytes(health.rss_bytes)} icon={} - color="blue" + tone="accent" /> } - color="purple" + tone="accent" /> } - color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} + tone={health.event_loop_p99_ms > 50 ? 'error' : 'success'} /> } - color="accent" + tone="accent" /> @@ -1037,31 +997,31 @@ function NodesTab({ label={t('admin.remnawave.nodes.stats.total', 'Total')} value={stats.total} icon={} - color="accent" + tone="accent" /> } - color="green" + tone="success" /> } - color="red" + tone="error" /> } - color="accent" + tone="accent" /> } - color="blue" + tone="accent" /> @@ -1170,25 +1130,25 @@ function SquadsTab({ label={t('admin.remnawave.squads.stats.total', 'Total')} value={stats.total} icon={} - color="accent" + tone="accent" /> } - color="green" + tone="success" /> } - color="blue" + tone="accent" /> } - color="purple" + tone="accent" /> diff --git a/src/pages/AdminRemnawaveSquadDetail.tsx b/src/pages/AdminRemnawaveSquadDetail.tsx index 985767e..d4034c0 100644 --- a/src/pages/AdminRemnawaveSquadDetail.tsx +++ b/src/pages/AdminRemnawaveSquadDetail.tsx @@ -3,7 +3,8 @@ 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 { ServerIcon, UsersIcon, CheckIcon, XIcon, BanknotesIcon } from '../components/icons'; +import { StatCard } from '@/components/stats'; import Twemoji from 'react-twemoji'; import { getFlagEmoji } from '../utils/subscriptionHelpers'; @@ -126,43 +127,33 @@ export default function AdminRemnawaveSquadDetail() { {t('admin.remnawave.squads.statsTitle', 'Statistics')}
-
-
- - {t('admin.remnawave.squads.members', 'Members')} -
-

{squad.members_count}

-
-
-
- - {t('admin.remnawave.squads.inbounds', 'Inbounds')} -
-

{squad.inbounds_count}

-
+ } + tone="neutral" + /> + } + tone="neutral" + /> {squad.is_synced && ( <> -
-
- - {t('admin.remnawave.squads.users', 'Users')} -
-

- {squad.current_users ?? 0} - - {' '} - / {squad.max_users ?? '∞'} - -

-
-
-
- {t('admin.remnawave.squads.price', 'Price')} -
-

- {((squad.price_kopeks ?? 0) / 100).toFixed(0)} ₽ -

-
+ } + tone="neutral" + subValue={`/ ${squad.max_users ?? '∞'}`} + /> + } + tone="neutral" + /> )}
diff --git a/src/pages/AdminRoles.tsx b/src/pages/AdminRoles.tsx index 7407add..746e235 100644 --- a/src/pages/AdminRoles.tsx +++ b/src/pages/AdminRoles.tsx @@ -7,7 +7,16 @@ import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePermissionStore } from '@/store/permissions'; import { usePlatform } from '@/platform/hooks/usePlatform'; import { useFocusTrap } from '@/hooks/useFocusTrap'; -import { BackIcon, PlusIcon, EditIcon, TrashIcon, ShieldIcon } from '@/components/icons'; +import { + BackIcon, + PlusIcon, + EditIcon, + TrashIcon, + ShieldIcon, + CheckCircleIcon, + CogIcon, +} from '@/components/icons'; +import { StatCard } from '@/components/stats'; export default function AdminRoles() { const { t } = useTranslation(); @@ -90,22 +99,24 @@ export default function AdminRoles() { {/* Stats Overview */} {sortedRoles.length > 0 && (
-
-
{sortedRoles.length}
-
{t('admin.roles.stats.totalRoles')}
-
-
-
- {sortedRoles.filter((r) => r.is_active).length} -
-
{t('admin.roles.stats.active')}
-
-
-
- {sortedRoles.filter((r) => r.is_system).length} -
-
{t('admin.roles.stats.system')}
-
+ } + tone="neutral" + /> + r.is_active).length} + icon={} + tone="accent" + /> + r.is_system).length} + icon={} + tone="warning" + />
)} diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index e779476..b87dec4 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -9,7 +9,17 @@ import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { copyToClipboard as copyText } from '../utils/clipboard'; import { usePlatform } from '../platform/hooks/usePlatform'; -import { BackIcon, SettingsIcon, TicketIcon, XIcon } from '@/components/icons'; +import { + BackIcon, + CheckCircleIcon, + ClockIcon, + InboxIcon, + SettingsIcon, + TicketIcon, + XCircleIcon, + XIcon, +} from '@/components/icons'; +import { StatCard } from '@/components/stats'; interface MediaAttachment { id: string; @@ -278,25 +288,37 @@ export default function AdminTickets() { {/* Stats */} {stats && (
-
-
{stats.total}
-
{t('admin.tickets.total')}
-
-
-
{stats.open}
-
{t('admin.tickets.statusOpen')}
-
-
-
{stats.pending}
-
{t('admin.tickets.statusPending')}
-
-
-
{stats.answered}
-
{t('admin.tickets.statusAnswered')}
-
-
-
{stats.closed}
-
{t('admin.tickets.statusClosed')}
+ } + tone="neutral" + /> + } + tone="accent" + /> + } + tone="warning" + /> + } + tone="success" + /> +
+ } + tone="neutral" + />
)} diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index e32f2f4..315dd3b 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { useCurrency } from '../hooks/useCurrency'; import { adminUsersApi, type UserListItem } from '../api/adminUsers'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { StatCard } from '@/components/stats'; import { BackIcon, SearchIcon, @@ -12,33 +13,13 @@ import { ChevronRightIcon, RefreshIcon, TelegramSmallIcon as TelegramIcon, + UsersIcon, + CheckCircleIcon, + SubscriptionIcon, + UserPlusIcon, + BanIcon, } from '@/components/icons'; -interface StatCardProps { - title: string; - value: string | number; - subtitle?: string; - color: 'blue' | 'green' | 'yellow' | 'red' | 'purple'; -} - -function StatCard({ title, value, subtitle, color }: StatCardProps) { - const colors = { - blue: 'bg-accent-500/20 text-accent-400 border-accent-500/30', - green: 'bg-success-500/20 text-success-400 border-success-500/30', - yellow: 'bg-warning-500/20 text-warning-400 border-warning-500/30', - red: 'bg-error-500/20 text-error-400 border-error-500/30', - purple: 'bg-accent-500/20 text-accent-400 border-accent-500/30', - }; - - return ( -
-
{value}
-
{title}
- {subtitle &&
{subtitle}
} -
- ); -} - function StatusBadge({ status }: { status: string }) { const styles: Record = { active: 'bg-success-500/20 text-success-400 border-success-500/30', @@ -208,26 +189,35 @@ export default function AdminUsers() { {/* Stats */} {stats && (
- } + tone="accent" + /> + } + tone="success" /> } + tone="accent" /> } + tone="warning" /> } + tone="error" />
)} diff --git a/src/pages/AdminWithdrawals.tsx b/src/pages/AdminWithdrawals.tsx index e1bb16a..4fd042d 100644 --- a/src/pages/AdminWithdrawals.tsx +++ b/src/pages/AdminWithdrawals.tsx @@ -4,7 +4,8 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { withdrawalApi, AdminWithdrawalItem } from '../api/withdrawals'; import { AdminBackButton } from '../components/admin'; -import { ChevronRightIcon } from '@/components/icons'; +import { ChevronRightIcon, ClockIcon, WalletIcon } from '@/components/icons'; +import { StatCard } from '@/components/stats'; import { useCurrency } from '../hooks/useCurrency'; import { formatDate, getWithdrawalStatusBadge, getRiskColor } from '../utils/withdrawalUtils'; @@ -57,20 +58,18 @@ export default function AdminWithdrawals() { {/* Overview Stats */} {data && (
-
-
{pendingCount}
-
- {t('admin.withdrawals.overview.pendingCount')} -
-
-
-
- {formatWithCurrency(pendingTotal / 100, 0)} -
-
- {t('admin.withdrawals.overview.pendingAmount')} -
-
+ } + tone="warning" + /> + } + tone="warning" + />
)} diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index e669e67..1a3657c 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -10,15 +10,23 @@ import { partnerApi } from '../api/partners'; import { withdrawalApi } from '../api/withdrawals'; import { CampaignCard } from '../components/partner/CampaignCard'; import { useCurrency } from '../hooks/useCurrency'; +import { StatCard } from '@/components/stats'; import { + ArrowDownIcon, + ArrowUpIcon, + BanknotesIcon, + CardIcon, CheckIcon, ClockIcon, CopyIcon, ExclamationIcon, + GiftIcon, LinkIcon, PartnerIcon, + PercentIcon, ShareIcon, TelegramIcon, + UserPlusIcon, UsersIcon, WalletIcon, } from '@/components/icons'; @@ -131,33 +139,33 @@ export default function Referral() {

{t('referral.terms.title')}

-
-
{t('referral.terms.commission')}
-
- {terms.commission_percent}% -
-
-
-
{t('referral.terms.minTopup')}
-
- {formatAmount(terms.minimum_topup_rubles)} {currencySymbol} -
-
+ } + tone="neutral" + /> + } + tone="neutral" + /> {showNewUserBonus && ( -
-
{t('referral.terms.newUserBonus')}
-
- {formatPositive(terms.first_topup_bonus_rubles)} -
-
+ } + tone="success" + /> )} {showInviterBonus && ( -
-
{t('referral.terms.inviterBonus')}
-
- {formatPositive(terms.inviter_bonus_rubles)} -
-
+ } + tone="success" + /> )}
@@ -237,23 +245,27 @@ export default function Referral() { {/* Stats Cards */}
-
-
{t('referral.stats.totalReferrals')}
-
{info?.total_referrals || 0}
-
- {info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()} -
-
-
-
{t('referral.stats.totalEarnings')}
-
- {formatPositive(info?.total_earnings_rubles || 0)} -
-
-
-
{t('referral.stats.commissionRate')}
-
{info?.commission_percent || 0}%
+
+ } + tone="neutral" + subValue={`${info?.active_referrals || 0} ${t('referral.stats.activeReferrals').toLowerCase()}`} + />
+ } + tone="success" + /> + } + tone="accent" + />
{/* Referral Links */} @@ -545,38 +557,38 @@ export default function Referral() {
-
-
{t('referral.withdrawal.available')}
-
- {formatWithCurrency(withdrawalBalance.available_total / 100)} -
-
-
-
- {t('referral.withdrawal.totalEarned')} -
-
- {formatWithCurrency(withdrawalBalance.total_earned / 100)} -
-
-
-
{t('referral.withdrawal.withdrawn')}
-
- {formatWithCurrency(withdrawalBalance.withdrawn / 100)} -
-
-
-
{t('referral.withdrawal.spent')}
-
- {formatWithCurrency(withdrawalBalance.referral_spent / 100)} -
-
-
-
{t('referral.withdrawal.pending')}
-
- {formatWithCurrency(withdrawalBalance.pending / 100)} -
+
+ } + tone="success" + />
+ } + tone="neutral" + /> + } + tone="neutral" + /> + } + tone="neutral" + /> + } + tone="warning" + />
diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx index cbc5a25..b95ef94 100644 --- a/src/pages/ReferralNetwork/components/NetworkStats.tsx +++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx @@ -1,4 +1,12 @@ import { useTranslation } from 'react-i18next'; +import { StatCard } from '@/components/stats'; +import { + BanknotesIcon, + CampaignIcon, + PartnerIcon, + UsersIcon, + WalletIcon, +} from '@/components/icons'; import type { NetworkGraphData } from '@/types/referralNetwork'; import { formatKopeksToRubles } from '../utils'; @@ -15,45 +23,37 @@ export function NetworkStats({ data, className }: NetworkStatsProps) { className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-2 backdrop-blur-md sm:p-3 ${className ?? ''}`} >
-
-

- {t('admin.referralNetwork.stats.totalUsers')} -

-

- {data.total_users.toLocaleString()} -

-
-
-

- {t('admin.referralNetwork.stats.totalReferrers')} -

-

- {data.total_referrers.toLocaleString()} -

-
-
-

- {t('admin.referralNetwork.stats.totalCampaigns')} -

-

- {data.total_campaigns.toLocaleString()} -

-
-
-

- {t('admin.referralNetwork.stats.subscriptionRevenue')} -

-

- {formatKopeksToRubles(data.total_subscription_revenue_kopeks)} ₽ -

-
-
-

- {t('admin.referralNetwork.stats.totalEarnings')} -

-

- {formatKopeksToRubles(data.total_earnings_kopeks)} ₽ -

+ } + tone="neutral" + /> + } + tone="neutral" + /> + } + tone="neutral" + /> + } + tone="accent" + /> +
+ } + tone="neutral" + />
From 1a3236f6506ffba87d65308a99b4cc0653b0d0ad Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sat, 6 Jun 2026 21:44:07 +0300 Subject: [PATCH 02/40] fix(cabinet): equal-height StatCards so dashboard balance/referral align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a grid, the wrapping stretched to the row height but the StatCard inside only took content height, so the referral card (which has a subValue line) rendered taller than the balance card ('один толще другого'). Make StatCard fill its cell with h-full and stretch the StatsGrid links. Verified by rendering both cards against the built CSS: balance 86px->94px, now equal to referral's 94px. --- src/components/dashboard/StatsGrid.tsx | 4 ++-- src/components/stats/StatCard.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 7701566..79d280f 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -24,7 +24,7 @@ export default function StatsGrid({ return (
- + - + +
{label} {trailing} From 35428cc27d45335c11b8999391bd8a388090fe7b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 8 Jun 2026 00:23:31 +0300 Subject: [PATCH 03/40] fix(promocode): correct activation UX, error mapping and validity dates Map promocode errors by stable backend code (detail.code) instead of brittle English substring matching, so previously-unmapped codes (active_discount_exists, daily_limit, no_subscription_for_days, trial_*, etc.) show the right message instead of 'server error'; added the missing keys to all 4 locales. valid_until now anchors to end-of-day in the admin's LOCAL timezone on both create AND edit read-back, so codes no longer expire at the start of the day and editing no longer drifts the date forward for negative-offset admins. Allow 0-hour discount ('until first purchase'). Route user-facing discount deactivation to the endpoint that rolls back promocode usage when the discount came from a promocode, and parse the structured {code,message} error. --- src/components/PromoOffersSection.tsx | 26 +++++++++++++--- src/locales/en.json | 7 +++++ src/locales/fa.json | 7 +++++ src/locales/ru.json | 7 +++++ src/locales/zh.json | 7 +++++ src/pages/AdminPromocodeCreate.tsx | 24 ++++++++++++--- src/pages/Balance.tsx | 43 +++++++++++++++++---------- 7 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 00f22df..cb26698 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -118,9 +118,21 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio }, }); - // Deactivate discount mutation + // Deactivate discount mutation. + // A discount granted by a PROMOCODE must be turned off via the dedicated route + // (POST /cabinet/promocode/deactivate-discount), which also rolls back the promocode + // usage (deletes the PromoCodeUse, decrements current_uses, strips the promo group) so + // the user can re-activate it later. The plain clearActiveDiscount route only zeroes + // the discount fields and is correct for admin/offer-sourced discounts. const deactivateMutation = useMutation({ - mutationFn: promoApi.clearActiveDiscount, + mutationFn: async () => { + const source = activeDiscount?.source ?? ''; + if (source.startsWith('promocode:')) { + await promoApi.deactivateDiscount(); + } else { + await promoApi.clearActiveDiscount(); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['active-discount'] }); queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); @@ -131,8 +143,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio setTimeout(() => setSuccessMessage(null), 5000); }, onError: (error: unknown) => { - const axiosErr = error as { response?: { data?: { detail?: string } } }; - setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error')); + // detail may be a plain string (clearActiveDiscount route) or a structured + // { code, message } object (deactivate-discount route). Handle both. + const axiosErr = error as { + response?: { data?: { detail?: string | { message?: string } } }; + }; + const detail = axiosErr.response?.data?.detail; + const message = typeof detail === 'string' ? detail : detail?.message; + setErrorMessage(message || t('promo.deactivate.error')); setTimeout(() => setErrorMessage(null), 5000); }, diff --git a/src/locales/en.json b/src/locales/en.json index 32fdaad..4fcbc57 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -787,6 +787,13 @@ "not_yet_valid": "Promo code is not yet active", "used": "Promo code has already been used", "already_used_by_user": "You have already used this promo code", + "active_discount_exists": "You already have an active discount. Deactivate it first.", + "no_subscription_for_days": "This promo code requires an active or expired subscription", + "subscription_not_found": "Subscription not found", + "not_first_purchase": "This promo code is only available for your first purchase", + "daily_limit": "Too many promo code activations today", + "trial_subscription_exists": "You already have a subscription, so this trial code can't be applied", + "trial_provisioning_failed": "Couldn't provision the trial right now, please try again later", "user_not_found": "User not found", "server_error": "Server error" }, diff --git a/src/locales/fa.json b/src/locales/fa.json index 847874b..b6ef96c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -674,6 +674,13 @@ "not_yet_valid": "کد تخفیف هنوز فعال نشده", "used": "کد تخفیف قبلاً استفاده شده", "already_used_by_user": "شما قبلاً از این کد استفاده کرده‌اید", + "active_discount_exists": "شما در حال حاضر یک تخفیف فعال دارید. ابتدا آن را غیرفعال کنید.", + "no_subscription_for_days": "این کد تخفیف به اشتراک فعال یا منقضی‌شده نیاز دارد", + "subscription_not_found": "اشتراک یافت نشد", + "not_first_purchase": "این کد تخفیف فقط برای اولین خرید در دسترس است", + "daily_limit": "تعداد فعال‌سازی کد تخفیف امروز بیش از حد است", + "trial_subscription_exists": "شما در حال حاضر اشتراک دارید، بنابراین این کد آزمایشی قابل اعمال نیست", + "trial_provisioning_failed": "در حال حاضر امکان فعال‌سازی نسخه آزمایشی نیست، لطفاً بعداً تلاش کنید", "user_not_found": "کاربر یافت نشد", "server_error": "خطای سرور" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 588db82..1d4f2e5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -816,6 +816,13 @@ "not_yet_valid": "Промокод ещё не начал действовать", "used": "Промокод уже использован", "already_used_by_user": "Вы уже использовали этот промокод", + "active_discount_exists": "У вас уже есть активная скидка. Сначала отключите её.", + "no_subscription_for_days": "Для этого промокода нужна активная или истёкшая подписка", + "subscription_not_found": "Подписка не найдена", + "not_first_purchase": "Промокод доступен только для первой покупки", + "daily_limit": "Слишком много активаций промокодов за сегодня", + "trial_subscription_exists": "У вас уже есть подписка, поэтому триал-промокод применить нельзя", + "trial_provisioning_failed": "Не удалось выдать триал прямо сейчас, попробуйте позже", "user_not_found": "Пользователь не найден", "server_error": "Ошибка сервера" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 727becb..585c8d4 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -674,6 +674,13 @@ "not_yet_valid": "优惠码尚未生效", "used": "优惠码已被使用", "already_used_by_user": "您已使用过此优惠码", + "active_discount_exists": "您已有一个生效中的折扣,请先停用它。", + "no_subscription_for_days": "此优惠码需要有效或已过期的订阅", + "subscription_not_found": "未找到订阅", + "not_first_purchase": "此优惠码仅限首次购买使用", + "daily_limit": "今日优惠码激活次数过多", + "trial_subscription_exists": "您已有订阅,因此无法使用此试用优惠码", + "trial_provisioning_failed": "暂时无法开通试用,请稍后再试", "user_not_found": "用户不存在", "server_error": "服务器错误" }, diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index 76bb1ba..f36462a 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -16,6 +16,16 @@ import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; import { BackIcon, RefreshIcon } from '@/components/icons'; +// valid_until is created as end-of-day in the admin's LOCAL tz, then stored/returned +// as a UTC instant. Reading the picker back must convert UTC -> local date, otherwise +// negative-offset admins see tomorrow's date and re-saving drifts the expiry forward a +// day each time. (Mirror of DateField's local toISO; must NOT slice the raw UTC string.) +const utcInstantToLocalDateInput = (iso: string): string => { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +}; + export default function AdminPromocodeCreate() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -76,7 +86,7 @@ export default function AdminPromocodeCreate() { setMaxUses(data.max_uses || 1); setIsActive(data.is_active ?? true); setFirstPurchaseOnly(data.first_purchase_only || false); - setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : ''); + setValidUntil(data.valid_until ? utcInstantToLocalDateInput(data.valid_until) : ''); setPromoGroupId(data.promo_group_id || null); setTariffId(data.tariff_id || null); return data; @@ -120,7 +130,12 @@ export default function AdminPromocodeCreate() { max_uses: maxUsesValue, is_active: isActive, first_purchase_only: firstPurchaseOnly, - valid_until: validUntil ? new Date(validUntil).toISOString() : null, + // The picker yields a date-only 'YYYY-MM-DD'. A promo "valid until D" must + // stay valid through the WHOLE of day D, so anchor to end-of-day in the + // admin's local timezone. `new Date('YYYY-MM-DD')` parses as UTC midnight + // (the START of the day) — for a GMT+3 admin that made a code picked for + // "today" already expired by 3am, surfacing as a bogus "expired" error. + valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null, promo_group_id: type === 'promo_group' ? promoGroupId : null, ...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}), }; @@ -145,7 +160,8 @@ export default function AdminPromocodeCreate() { if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) return false; if (type === 'promo_group' && !promoGroupId) return false; - if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue <= 0)) + // For discount, validity hours of 0 = "until first purchase" (perpetual) — allowed. + if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue < 0)) return false; return true; }; @@ -410,7 +426,7 @@ export default function AdminPromocodeCreate() { } }} className="input w-32" - min={1} + min={0} placeholder="0" /> {t('admin.promocodes.form.hours')} diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 7dfb2bd..729355b 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -159,22 +159,33 @@ export default function Balance() { queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); } } catch (error: unknown) { - const axiosError = error as { response?: { data?: { detail?: string } } }; - const errorDetail = axiosError.response?.data?.detail || 'server_error'; - const detail = errorDetail.toLowerCase(); - const errorKey = detail.includes('not found') - ? 'not_found' - : detail.includes('deactivated') - ? 'inactive' - : detail.includes('not yet active') - ? 'not_yet_valid' - : detail.includes('expired') - ? 'expired' - : detail.includes('fully used') - ? 'used' - : detail.includes('already used') - ? 'already_used_by_user' - : 'server_error'; + // Backend returns a structured error: detail = { code, message }. We map + // the stable machine code to a localized string. (The old contract + // substring-matched English prose and silently degraded every unmapped + // code — active_discount_exists, daily_limit, … — to "server error".) + const axiosError = error as { + response?: { data?: { detail?: { code?: string } | string } }; + }; + const detail = axiosError.response?.data?.detail; + const code = typeof detail === 'object' && detail ? detail.code : undefined; + const knownErrorKeys = [ + 'not_found', + 'expired', + 'inactive', + 'not_yet_valid', + 'used', + 'already_used_by_user', + 'active_discount_exists', + 'no_subscription_for_days', + 'subscription_not_found', + 'not_first_purchase', + 'daily_limit', + 'trial_subscription_exists', + 'trial_provisioning_failed', + 'user_not_found', + 'server_error', + ]; + const errorKey = code && knownErrorKeys.includes(code) ? code : 'server_error'; setPromocodeError(t(`balance.promocode.errors.${errorKey}`)); setPromoSelectSubs(null); setPromoSelectCode(null); From d37872fd4fa80d781377fc2b2169b3af22b77394 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 8 Jun 2026 11:11:56 +0300 Subject: [PATCH 04/40] fix(cabinet): harden global encoders against lone UTF-16 surrogates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lone (unpaired) UTF-16 surrogate — a truncated emoji in a backend name/remark embedded in a subscription/connection URL — makes encodeURI/encodeURIComponent throw a URIError on iOS WebKit (V8: 'URI malformed'; Safari/JSC: 'String contained an illegal UTF-16 sequence'). qrcode.react calls encodeURI internally, so such a value crashed the QR render and tripped the page-level ErrorBoundary ('Something went wrong / String contained an illegal UTF-16 sequence / Try again'). Rather than wrap each (current and future) call site — and third-party libs we can't edit — sanitise at the single chokepoint: patch the global encodeURI/encodeURIComponent at bootstrap to replace lone surrogates with U+FFFD (same remedy as toWellFormed()). Fail-safe, not fail-broken: verified byte-identical output for all well-formed strings (URLs unaffected) and no-throw on lone surrogates for the real call patterns (qrcode.react and btoa(unescape(encodeURIComponent(...)))). A fast path skips the rewrite when no surrogate code units are present, so the hot path (every request URL) is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/main.tsx | 7 ++++ src/utils/installEncodingSurrogateGuard.ts | 39 +++++++++++++++++++ src/utils/sanitizeSurrogates.ts | 45 ++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/utils/installEncodingSurrogateGuard.ts create mode 100644 src/utils/sanitizeSurrogates.ts diff --git a/src/main.tsx b/src/main.tsx index 022c153..d4981db 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { isFullscreen, } from '@telegram-apps/sdk-react'; import { clearStaleSessionIfNeeded } from './utils/token'; +import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard'; import { useAuthStore } from './store/auth'; import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -29,6 +30,12 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra import { applyTelegramLanguage } from './i18n'; import './styles/globals.css'; +// Harden the global encoders against lone UTF-16 surrogates (truncated emoji in +// backend names/remarks) BEFORE anything renders or fetches — otherwise such a +// string crashes any encodeURI/encodeURIComponent/btoa path on iOS WebKit, +// including qrcode.react's internal encodeURI. See installEncodingSurrogateGuard. +installEncodingSurrogateGuard(); + // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). // @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally. // Without this, init() throws LaunchParamsRetrieveError on affected devices. diff --git a/src/utils/installEncodingSurrogateGuard.ts b/src/utils/installEncodingSurrogateGuard.ts new file mode 100644 index 0000000..3604c60 --- /dev/null +++ b/src/utils/installEncodingSurrogateGuard.ts @@ -0,0 +1,39 @@ +import { sanitizeSurrogates } from './sanitizeSurrogates'; + +/** + * App-wide guard against the "String contained an illegal UTF-16 sequence" crash. + * + * A lone (unpaired) UTF-16 surrogate — typically a truncated emoji in a backend + * name/remark that ends up in a subscription/connection URL — makes encodeURI / + * encodeURIComponent throw a URIError on iOS WebKit/JavaScriptCore (V8: "URI + * malformed"; Safari/JSC: "String contained an illegal UTF-16 sequence"). Any + * code path that encodes such a string crashes — including third-party libs we + * can't edit (e.g. qrcode.react calls encodeURI internally) and any encode call + * added later. The base64 idiom btoa(unescape(encodeURIComponent(x))) is covered + * transitively, since its encodeURIComponent is guarded here. (We do NOT patch + * btoa itself: it rejects every char > U+00FF — Cyrillic, emoji, even the U+FFFD + * replacement — with a separate "Invalid character" error that sanitising + * surrogates cannot fix, and nothing base64-encodes raw Unicode directly.) + * + * Instead of wrapping every (current and future) call site, we sanitise at the + * single chokepoint: the global encoders themselves. This is fail-safe, not + * fail-broken — for any well-formed string there are no lone surrogates, so the + * output is byte-for-byte identical; only strings that would otherwise have + * thrown get their lone surrogates replaced with U+FFFD (the same remedy as + * String.prototype.toWellFormed()). + * + * Must run before any rendering or network call. Idempotent. + */ +export function installEncodingSurrogateGuard(): void { + const flag = '__surrogateEncoderGuardInstalled'; + const g = globalThis as typeof globalThis & Record; + if (g[flag]) return; + g[flag] = true; + + const nativeEncodeURI = globalThis.encodeURI; + const nativeEncodeURIComponent = globalThis.encodeURIComponent; + + globalThis.encodeURI = (uri: string): string => nativeEncodeURI(sanitizeSurrogates(String(uri))); + globalThis.encodeURIComponent = (uriComponent: string | number | boolean): string => + nativeEncodeURIComponent(sanitizeSurrogates(String(uriComponent))); +} diff --git a/src/utils/sanitizeSurrogates.ts b/src/utils/sanitizeSurrogates.ts new file mode 100644 index 0000000..ea4fc1d --- /dev/null +++ b/src/utils/sanitizeSurrogates.ts @@ -0,0 +1,45 @@ +/** + * Replace unpaired UTF-16 surrogates with the Unicode replacement char (U+FFFD). + * + * Why: a lone surrogate — e.g. a truncated emoji in a server/profile name embedded + * in a subscription URL's `#remark` — makes `encodeURI()` throw. On V8 the message is + * "URI malformed"; on iOS WebKit (JavaScriptCore, used by Telegram on iOS) it is + * "String contained an illegal UTF-16 sequence". `qrcode.react` calls `encodeURI` + * internally (qrcodegen `toUtf8ByteArray`), so such a string crashes the QR render and + * trips the page-level ErrorBoundary. `btoa(unescape(encodeURIComponent(...)))` and the + * Telegram native bridge throw on the same input. + * + * Sanitising keeps the link working (the surrogate only ever lives in a cosmetic remark) + * and renders a replacement glyph instead of taking the whole page down. Implemented + * without lookbehind for broad WebView compatibility. + */ +// Any surrogate code unit at all (paired or not). The vast majority of strings +// (ASCII URLs, ids, tokens) contain none, so this lets us bail out in O(1) before +// the per-char rebuild — important because the global encoder guard runs this on +// every encodeURIComponent/encodeURI/btoa call. +const SURROGATE_RANGE = /[\uD800-\uDFFF]/; + +export function sanitizeSurrogates(value: string): string { + if (!SURROGATE_RANGE.test(value)) return value; + + let result = ''; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + // High surrogate: valid only if immediately followed by a low surrogate. + const next = value.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + result += value[i] + value[i + 1]; + i++; + } else { + result += '�'; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + // Low surrogate with no preceding high surrogate. + result += '�'; + } else { + result += value[i]; + } + } + return result; +} From 325e221e3209b430a7f5fd95df325802dae5db97 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 8 Jun 2026 13:11:19 +0300 Subject: [PATCH 05/40] fix(connection): stop ERR_UNKNOWN_URL_SCHEME when opening app deep links on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram bug #654272: opening the connection app link (immediate-open / connect button) showed a full-page net::ERR_UNKNOWN_URL_SCHEME on Android and silently failed on iOS, while working on desktop. Cause: a programmatic top-level navigation to a custom scheme (happ://, v2rayng://, …) via window.location.href is rendered as a full-page error inside in-app WebViews (Telegram/Yandex) on Android and does nothing on iOS — also wiping the fallback UI. Add openAppScheme(): http(s) navigate normally; custom schemes launch via a hidden, contained iframe so a failed launch never replaces the page — the app opens if installed, otherwise the manual 'Open app' link stays usable (a user tap is the reliable trigger). Applied in DeepLinkRedirect.tsx, Connection.tsx and the static public/miniapp/redirect.html (which now also surfaces the manual button immediately instead of after 2s). --- public/miniapp/redirect.html | 27 +++++++++++++++++------ src/pages/Connection.tsx | 8 +++++-- src/pages/DeepLinkRedirect.tsx | 7 ++++-- src/utils/openAppScheme.ts | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 src/utils/openAppScheme.ts diff --git a/public/miniapp/redirect.html b/public/miniapp/redirect.html index b6e5151..cca3db1 100644 --- a/public/miniapp/redirect.html +++ b/public/miniapp/redirect.html @@ -148,14 +148,27 @@ const manualBtn = document.getElementById('manualBtn'); manualBtn.href = url; - // Try to open the URL scheme directly - // This page is opened in external browser, so custom schemes work - window.location.href = url; + // Attempt to launch the app WITHOUT a top-level navigation. A direct + // `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME + // inside Android in-app browsers (Telegram/Yandex/…) and silently fails on + // iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is + // contained: the app opens if installed, otherwise the failure stays inside + // the (invisible) frame and the button below stays usable. + try { + var frame = document.createElement('iframe'); + frame.style.display = 'none'; + frame.src = url; + document.body.appendChild(frame); + setTimeout(function() { + try { frame.parentNode.removeChild(frame); } catch (e) {} + }, 2000); + } catch (e) { + window.location.href = url; + } - // Show manual button after a delay in case redirect didn't work - setTimeout(function() { - document.getElementById('errorBlock').classList.add('show'); - }, 2000); + // Programmatic opening is unreliable in in-app browsers, so surface the + // manual "Open app" button immediately — a user tap is the reliable trigger. + document.getElementById('errorBlock').classList.add('show'); })(); diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index f9b3833..5a3f7b7 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -8,6 +8,7 @@ import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useHaptic } from '@/platform'; import { SettingsIcon } from '@/components/icons'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; +import { openAppScheme } from '../utils/openAppScheme'; import { isHappCryptolinkMode, resolveConnectionUrlForUi } from '../utils/connectionLink'; import { useAuthStore } from '../store/auth'; import type { AppConfig, RemnawavePlatformData } from '../types'; @@ -133,8 +134,11 @@ export default function Connection() { } } - // In regular browsers open deeplink directly (without intermediate redirect page). - window.location.href = resolved; + // In regular browsers open the deeplink directly. openAppScheme uses a contained + // iframe for custom schemes so an unresolved scheme doesn't paint a full-page + // net::ERR_UNKNOWN_URL_SCHEME (Android) / silently fail (iOS); http(s) links + // still navigate normally. (Telegram bug #654272.) + openAppScheme(resolved); }, [isTelegramWebApp, i18n.language, resolveUrl, connectionLink?.connect_mode, qrConnectionUrl], ); diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index 4479b28..471e7e5 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '../api/branding'; import { copyToClipboard } from '../utils/clipboard'; +import { openAppScheme } from '../utils/openAppScheme'; import { CheckIcon, CopyIcon, @@ -97,10 +98,12 @@ export default function DeepLinkRedirect() { const appName = appInfo?.name || appParam || 'VPN'; const appIcon = appInfo?.icon || appName[0]?.toUpperCase() || 'V'; - // Open deep link - same as miniapp, just window.location.href + // Open deep link via a contained iframe attempt so a custom scheme the in-app + // WebView can't resolve doesn't replace this page with net::ERR_UNKNOWN_URL_SCHEME + // (Android) / silently fail (iOS) and wipe the fallback UI. See openAppScheme. const openDeepLink = useCallback(() => { if (!deepLink || !isValidDeepLink(deepLink)) return; - window.location.href = deepLink; + openAppScheme(deepLink); }, [deepLink]); // Countdown timer effect diff --git a/src/utils/openAppScheme.ts b/src/utils/openAppScheme.ts new file mode 100644 index 0000000..6f60bc1 --- /dev/null +++ b/src/utils/openAppScheme.ts @@ -0,0 +1,39 @@ +/** + * Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without + * crashing the page inside in-app browsers. + * + * Why not `window.location.href = scheme`: a programmatic top-level navigation to a + * scheme the WebView can't resolve renders a full-page error — on Android in-app + * browsers (Telegram/Yandex/…) `net::ERR_UNKNOWN_URL_SCHEME`, on iOS it silently does + * nothing — which destroys the fallback UI (Telegram bug #654272). A hidden