mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
Merge remote-tracking branch 'origin/dev' into pr-548
This commit is contained in:
@@ -456,7 +456,8 @@ export const adminUsersApi = {
|
|||||||
| 'traffic'
|
| 'traffic'
|
||||||
| 'last_activity'
|
| 'last_activity'
|
||||||
| 'total_spent'
|
| 'total_spent'
|
||||||
| 'purchase_count';
|
| 'purchase_count'
|
||||||
|
| 'subscription_end_date';
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<UsersListResponse> => {
|
): Promise<UsersListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/users', { params });
|
const response = await apiClient.get('/cabinet/admin/users', { params });
|
||||||
@@ -518,6 +519,19 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Delete one of the user's subscriptions (multi-tariff: trials pile up)
|
||||||
|
deleteSubscription: async (
|
||||||
|
userId: number,
|
||||||
|
subId: number,
|
||||||
|
force = false,
|
||||||
|
): Promise<{ status: string }> => {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/cabinet/admin/users/${userId}/subscriptions/${subId}`,
|
||||||
|
{ params: force ? { force: true } : undefined },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
updateStatus: async (
|
updateStatus: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface PromoCode {
|
|||||||
balance_bonus_kopeks: number;
|
balance_bonus_kopeks: number;
|
||||||
balance_bonus_rubles: number;
|
balance_bonus_rubles: number;
|
||||||
subscription_days: number;
|
subscription_days: number;
|
||||||
|
/** Гигабайты к подписке — третья составляющая набора бонусов. */
|
||||||
|
traffic_gb: number;
|
||||||
max_uses: number;
|
max_uses: number;
|
||||||
current_uses: number;
|
current_uses: number;
|
||||||
uses_left: number;
|
uses_left: number;
|
||||||
@@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest {
|
|||||||
type: PromoCodeType;
|
type: PromoCodeType;
|
||||||
balance_bonus_kopeks?: number;
|
balance_bonus_kopeks?: number;
|
||||||
subscription_days?: number;
|
subscription_days?: number;
|
||||||
|
traffic_gb?: number;
|
||||||
max_uses?: number;
|
max_uses?: number;
|
||||||
valid_from?: string;
|
valid_from?: string;
|
||||||
valid_until?: string | null;
|
valid_until?: string | null;
|
||||||
@@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest {
|
|||||||
type?: PromoCodeType;
|
type?: PromoCodeType;
|
||||||
balance_bonus_kopeks?: number;
|
balance_bonus_kopeks?: number;
|
||||||
subscription_days?: number;
|
subscription_days?: number;
|
||||||
|
traffic_gb?: number;
|
||||||
max_uses?: number;
|
max_uses?: number;
|
||||||
valid_from?: string;
|
valid_from?: string;
|
||||||
valid_until?: string | null;
|
valid_until?: string | null;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
const [oidcError, setOidcError] = useState('');
|
const [oidcError, setOidcError] = useState('');
|
||||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||||
const [scriptFailed, setScriptFailed] = useState(false);
|
const [scriptFailed, setScriptFailed] = useState(false);
|
||||||
|
// Lets the user opt into deep-link auth manually, without waiting for the
|
||||||
|
// Telegram widget script to fail. See #<issue-number>.
|
||||||
|
const [manualDeepLink, setManualDeepLink] = useState(false);
|
||||||
|
const showDeepLinkUI = scriptFailed || manualDeepLink;
|
||||||
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
|
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
|
||||||
|
|
||||||
// Deep link auth state
|
// Deep link auth state
|
||||||
@@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
|
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
|
// showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер
|
||||||
|
// виджета размонтирован, а при возврате «Назад к виджету» сам по себе
|
||||||
|
// эффект не перезапустится — на legacy-пути scriptLoaded не меняется
|
||||||
|
// никогда, поэтому ни одна из остальных зависимостей не дрогнет, и
|
||||||
|
// пользователь получил бы пустое место вместо виджета.
|
||||||
|
if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
while (container.firstChild) {
|
while (container.firstChild) {
|
||||||
@@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]);
|
}, [
|
||||||
|
showDeepLinkUI,
|
||||||
|
isOIDC,
|
||||||
|
botUsername,
|
||||||
|
widgetConfig,
|
||||||
|
loginWithTelegramWidget,
|
||||||
|
navigate,
|
||||||
|
handleScriptFailed,
|
||||||
|
]);
|
||||||
|
|
||||||
// Deep link auth: request token and start polling with recursive setTimeout
|
// Deep link auth: request token and start polling with recursive setTimeout
|
||||||
const startDeepLinkAuth = useCallback(async () => {
|
const startDeepLinkAuth = useCallback(async () => {
|
||||||
@@ -336,9 +353,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
}
|
}
|
||||||
}, [botUsername, loginWithDeepLink, navigate, t]);
|
}, [botUsername, loginWithDeepLink, navigate, t]);
|
||||||
|
|
||||||
// Auto-start deep link auth when script fails (with cancellation for Strict Mode)
|
// Auto-start deep link auth when script fails OR the user opts in manually
|
||||||
|
// (with cancellation for Strict Mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scriptFailed && !deepLinkToken && !deepLinkPolling) {
|
if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
if (!cancelled) await startDeepLinkAuth();
|
if (!cancelled) await startDeepLinkAuth();
|
||||||
@@ -348,7 +366,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
|
}, [showDeepLinkUI, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
|
||||||
|
|
||||||
// Resume polling immediately when user returns to the page (e.g. after confirming in Telegram)
|
// Resume polling immediately when user returns to the page (e.g. after confirming in Telegram)
|
||||||
// Browsers throttle setTimeout in background tabs, so polling may have stalled.
|
// Browsers throttle setTimeout in background tabs, so polling may have stalled.
|
||||||
@@ -431,8 +449,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep link fallback UI
|
// Deep link UI — shown either as an automatic fallback (widget script
|
||||||
if (scriptFailed) {
|
// failed to load) or because the user explicitly chose this method.
|
||||||
|
if (showDeepLinkUI) {
|
||||||
const resolvedBotUsername = deepLinkBotUsername || botUsername;
|
const resolvedBotUsername = deepLinkBotUsername || botUsername;
|
||||||
const deepLinkUrl = deepLinkToken
|
const deepLinkUrl = deepLinkToken
|
||||||
? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}`
|
? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}`
|
||||||
@@ -443,7 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
<div className="flex flex-col items-center space-y-5">
|
<div className="flex flex-col items-center space-y-5">
|
||||||
{/* Info message */}
|
{/* Info message */}
|
||||||
<p className="max-w-xs text-center text-xs text-dark-400">
|
<p className="max-w-xs text-center text-xs text-dark-400">
|
||||||
{t('auth.telegramWidgetBlocked')}
|
{t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{deepLinkToken && deepLinkUrl ? (
|
{deepLinkToken && deepLinkUrl ? (
|
||||||
@@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Only offer a way back if the widget actually works — if the
|
||||||
|
script failed there is nothing to go back to. */}
|
||||||
|
{!scriptFailed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
|
||||||
|
if (expireTimeoutRef.current) clearTimeout(expireTimeoutRef.current);
|
||||||
|
pollTimeoutRef.current = null;
|
||||||
|
expireTimeoutRef.current = null;
|
||||||
|
setDeepLinkToken(null);
|
||||||
|
setDeepLinkPolling(false);
|
||||||
|
setDeepLinkError('');
|
||||||
|
setManualDeepLink(false);
|
||||||
|
}}
|
||||||
|
className="text-xs text-dark-400 underline decoration-dotted transition-colors hover:text-dark-300"
|
||||||
|
>
|
||||||
|
{t('auth.backToWidget')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
<div ref={containerRef} className="flex justify-center" />
|
<div ref={containerRef} className="flex justify-center" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-center">
|
{/* Referral deep link — only relevant for not-yet-registered users who
|
||||||
<p className="mb-2 text-xs text-dark-400">{t('auth.orOpenInApp')}</p>
|
arrived via a referral link; the bot itself handles registering
|
||||||
|
them with the code attached. Hidden otherwise to avoid a third,
|
||||||
|
visually-identical "Telegram" entry point next to the two auth
|
||||||
|
methods below. */}
|
||||||
|
{referralCode && (
|
||||||
<a
|
<a
|
||||||
href={
|
href={`https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`}
|
||||||
referralCode
|
|
||||||
? `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`
|
|
||||||
: `https://t.me/${botUsername}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
|
className="text-telegram-blue inline-flex items-center text-xs hover:underline"
|
||||||
>
|
>
|
||||||
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
{t('auth.orOpenInApp')} @{botUsername}
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
||||||
</svg>
|
|
||||||
@{botUsername}
|
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-xs items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
|
<span className="text-[11px] text-dark-500">{t('common.or')}</span>
|
||||||
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Manual opt-in: same deep-link flow used as the anti-block fallback,
|
||||||
|
offered here as an explicit equal alternative to the widget for
|
||||||
|
users who'd rather confirm in the bot than type a phone number. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManualDeepLink(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-dark-700 bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-800"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5 text-telegram-blue" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
|
</svg>
|
||||||
|
{t('auth.loginWithBot')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
|
|||||||
onRemoveTraffic: (purchaseId: number) => Promise<void>;
|
onRemoveTraffic: (purchaseId: number) => Promise<void>;
|
||||||
onResetDevices: () => Promise<void>;
|
onResetDevices: () => Promise<void>;
|
||||||
onCancelSbpRecurring: () => Promise<void>;
|
onCancelSbpRecurring: () => Promise<void>;
|
||||||
|
onDeleteSubscription: () => Promise<void>;
|
||||||
onDeleteDevice: (hwid: string) => Promise<void>;
|
onDeleteDevice: (hwid: string) => Promise<void>;
|
||||||
onRenameDevice: (hwid: string) => Promise<void>;
|
onRenameDevice: (hwid: string) => Promise<void>;
|
||||||
onLoadDevices: () => Promise<void>;
|
onLoadDevices: () => Promise<void>;
|
||||||
@@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
|||||||
onRemoveTraffic,
|
onRemoveTraffic,
|
||||||
onResetDevices,
|
onResetDevices,
|
||||||
onCancelSbpRecurring,
|
onCancelSbpRecurring,
|
||||||
|
onDeleteSubscription,
|
||||||
onDeleteDevice,
|
onDeleteDevice,
|
||||||
onRenameDevice,
|
onRenameDevice,
|
||||||
onLoadDevices,
|
onLoadDevices,
|
||||||
@@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete this subscription — in multi-tariff mode spent trials
|
||||||
|
pile up in the card, and removing one used to be possible
|
||||||
|
only through the bulk-actions screen. */}
|
||||||
|
{hasPermission('users:subscription') && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.subscription.deleteTitle')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-dark-400">
|
||||||
|
{t('admin.users.detail.subscription.deleteHint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
// Per-subscription confirm key: an armed confirm must not
|
||||||
|
// survive switching to another subscription in the picker.
|
||||||
|
onClick={() =>
|
||||||
|
onInlineConfirm(`deleteSubscription_${selectedSub.id}`, onDeleteSubscription)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `deleteSubscription_${selectedSub.id}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `deleteSubscription_${selectedSub.id}`
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.detail.subscription.deleteButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Traffic Packages */}
|
{/* Traffic Packages */}
|
||||||
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
|
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
|||||||
143
src/components/dashboard/ConnectDeviceTile.tsx
Normal file
143
src/components/dashboard/ConnectDeviceTile.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useHaptic } from '../../platform';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||||
|
|
||||||
|
interface ConnectDeviceTileProps {
|
||||||
|
subscription: {
|
||||||
|
id: number;
|
||||||
|
device_limit: number;
|
||||||
|
subscription_url?: string | null;
|
||||||
|
};
|
||||||
|
connectedDevices: number;
|
||||||
|
/** Процент израсходованного трафика — от него зависит акцентный цвет плитки. */
|
||||||
|
usedPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плитка «Подключить устройство».
|
||||||
|
*
|
||||||
|
* Живёт и в карточке активной подписки, и на главной: пользователь,
|
||||||
|
* которому подписку выдал бонус рекламной кампании, попадает на главную
|
||||||
|
* с готовым доступом — и без этой плитки не понимает, что делать дальше.
|
||||||
|
*/
|
||||||
|
export default function ConnectDeviceTile({
|
||||||
|
subscription,
|
||||||
|
connectedDevices,
|
||||||
|
usedPercent = 0,
|
||||||
|
}: ConnectDeviceTileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
const zone = useTrafficZone(usedPercent);
|
||||||
|
|
||||||
|
const isAtDeviceLimit =
|
||||||
|
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
||||||
|
|
||||||
|
if (!subscription.subscription_url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverBorderGradient
|
||||||
|
as="button"
|
||||||
|
accentColor={zone.mainHex}
|
||||||
|
disabled={isAtDeviceLimit}
|
||||||
|
onClick={() => {
|
||||||
|
if (isAtDeviceLimit) {
|
||||||
|
haptic.notification('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/connection?sub=${subscription.id}`);
|
||||||
|
}}
|
||||||
|
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300 ${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
data-onboarding="connect-devices"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{/* Monitor icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||||
|
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={zone.mainVar}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<path d="M12 17v4M8 21h8" />
|
||||||
|
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
||||||
|
{t('dashboard.connectDevice')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||||
|
{subscription.device_limit === 0
|
||||||
|
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||||
|
: t('dashboard.devicesOfMax', {
|
||||||
|
used: connectedDevices,
|
||||||
|
max: subscription.device_limit,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isAtDeviceLimit && (
|
||||||
|
<div
|
||||||
|
className="mt-1 text-[10px] font-medium"
|
||||||
|
style={{ color: 'rgb(var(--color-warning-400))' }}
|
||||||
|
>
|
||||||
|
{t('dashboard.deviceLimitReached')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device indicator */}
|
||||||
|
{subscription.device_limit === 0 ? (
|
||||||
|
<div className="flex flex-shrink-0 items-center text-lg text-dark-50/40" aria-hidden="true">
|
||||||
|
∞
|
||||||
|
</div>
|
||||||
|
) : subscription.device_limit <= 10 ? (
|
||||||
|
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||||
|
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: i < connectedDevices ? zone.mainVar : g.textGhost,
|
||||||
|
boxShadow: i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="h-[6px] w-full overflow-hidden rounded-full"
|
||||||
|
style={{ background: g.textGhost }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
||||||
|
background: zone.mainVar,
|
||||||
|
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
|
||||||
|
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HoverBorderGradient>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { uiLocale } from '@/utils/uiLocale';
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
import TrafficProgressBar from './TrafficProgressBar';
|
import TrafficProgressBar from './TrafficProgressBar';
|
||||||
import Sparkline from './Sparkline';
|
import Sparkline from './Sparkline';
|
||||||
|
import ConnectDeviceTile from './ConnectDeviceTile';
|
||||||
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
||||||
import { formatTraffic } from '../../utils/formatTraffic';
|
import { formatTraffic } from '../../utils/formatTraffic';
|
||||||
import { getGlassColors } from '../../utils/glassTheme';
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
|
||||||
import { CalendarIcon, RefreshIcon } from '@/components/icons';
|
import { CalendarIcon, RefreshIcon } from '@/components/icons';
|
||||||
import { useHaptic } from '../../platform';
|
|
||||||
import type { Subscription } from '../../types';
|
import type { Subscription } from '../../types';
|
||||||
|
|
||||||
interface SubscriptionCardActiveProps {
|
interface SubscriptionCardActiveProps {
|
||||||
@@ -35,7 +33,6 @@ export default function SubscriptionCardActive({
|
|||||||
connectedDevices,
|
connectedDevices,
|
||||||
}: SubscriptionCardActiveProps) {
|
}: SubscriptionCardActiveProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const g = getGlassColors(isDark);
|
const g = getGlassColors(isDark);
|
||||||
|
|
||||||
@@ -44,10 +41,6 @@ export default function SubscriptionCardActive({
|
|||||||
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
|
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
|
||||||
const zone = useTrafficZone(usedPercent);
|
const zone = useTrafficZone(usedPercent);
|
||||||
const animatedPercent = useAnimatedNumber(usedPercent);
|
const animatedPercent = useAnimatedNumber(usedPercent);
|
||||||
const haptic = useHaptic();
|
|
||||||
|
|
||||||
const isAtDeviceLimit =
|
|
||||||
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
|
||||||
|
|
||||||
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
|
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
|
||||||
const daysLeft = subscription.days_left;
|
const daysLeft = subscription.days_left;
|
||||||
@@ -161,109 +154,11 @@ export default function SubscriptionCardActive({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Connect Device Button ─── */}
|
{/* ─── Connect Device Button ─── */}
|
||||||
{subscription.subscription_url && (
|
<ConnectDeviceTile
|
||||||
<HoverBorderGradient
|
subscription={subscription}
|
||||||
as="button"
|
connectedDevices={connectedDevices}
|
||||||
accentColor={zone.mainHex}
|
usedPercent={usedPercent}
|
||||||
disabled={isAtDeviceLimit}
|
/>
|
||||||
onClick={() => {
|
|
||||||
if (isAtDeviceLimit) {
|
|
||||||
haptic.notification('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/connection?sub=${subscription.id}`);
|
|
||||||
}}
|
|
||||||
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
||||||
data-onboarding="connect-devices"
|
|
||||||
style={{ fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{/* Monitor icon */}
|
|
||||||
<div
|
|
||||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
|
||||||
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke={zone.mainVar}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
||||||
<path d="M12 17v4M8 21h8" />
|
|
||||||
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
|
||||||
{t('dashboard.connectDevice')}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
|
||||||
{subscription.device_limit === 0
|
|
||||||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
|
||||||
: t('dashboard.devicesOfMax', {
|
|
||||||
used: connectedDevices,
|
|
||||||
max: subscription.device_limit,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{isAtDeviceLimit && (
|
|
||||||
<div
|
|
||||||
className="mt-1 text-[10px] font-medium"
|
|
||||||
style={{ color: 'rgb(var(--color-warning-400))' }}
|
|
||||||
>
|
|
||||||
{t('dashboard.deviceLimitReached')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device indicator */}
|
|
||||||
{subscription.device_limit === 0 ? (
|
|
||||||
<div
|
|
||||||
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
∞
|
|
||||||
</div>
|
|
||||||
) : subscription.device_limit <= 10 ? (
|
|
||||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
|
||||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
background: i < connectedDevices ? zone.mainVar : g.textGhost,
|
|
||||||
boxShadow:
|
|
||||||
i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
|
||||||
<div
|
|
||||||
className="h-[6px] w-full overflow-hidden rounded-full"
|
|
||||||
style={{ background: g.textGhost }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
|
||||||
background: zone.mainVar,
|
|
||||||
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
|
|
||||||
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</HoverBorderGradient>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── Stats row: Tariff + Days Left ─── */}
|
{/* ─── Stats row: Tariff + Days Left ─── */}
|
||||||
<div className="mb-5 flex gap-2.5">
|
<div className="mb-5 flex gap-2.5">
|
||||||
|
|||||||
@@ -207,6 +207,9 @@
|
|||||||
"orOpenInApp": "Or open the bot in the app",
|
"orOpenInApp": "Or open the bot in the app",
|
||||||
"loginFailed": "Login Failed",
|
"loginFailed": "Login Failed",
|
||||||
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
|
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
|
||||||
|
"deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:",
|
||||||
|
"loginWithBot": "Login via bot",
|
||||||
|
"backToWidget": "Back to widget login",
|
||||||
"openBotToLogin": "Open bot to sign in",
|
"openBotToLogin": "Open bot to sign in",
|
||||||
"waitingForConfirmation": "Waiting for confirmation...",
|
"waitingForConfirmation": "Waiting for confirmation...",
|
||||||
"deepLinkExpired": "Link expired. Please try again.",
|
"deepLinkExpired": "Link expired. Please try again.",
|
||||||
@@ -3305,7 +3308,10 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"defaultTrialTariff": "Default trial tariff",
|
"defaultTrialTariff": "Default trial tariff",
|
||||||
"selectTariff": "Select tariff",
|
"selectTariff": "Select tariff",
|
||||||
"tariff": "Tariff"
|
"tariff": "Tariff",
|
||||||
|
"includeTraffic": "Traffic",
|
||||||
|
"trafficAmount": "Traffic amount",
|
||||||
|
"gb": "GB"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Promo code statistics",
|
"title": "Promo code statistics",
|
||||||
@@ -3379,7 +3385,8 @@
|
|||||||
"daysRequired": "Number of days must be greater than 0",
|
"daysRequired": "Number of days must be greater than 0",
|
||||||
"groupRequired": "Select a discount group",
|
"groupRequired": "Select a discount group",
|
||||||
"discountPercentInvalid": "Discount percent must be between 1 and 100",
|
"discountPercentInvalid": "Discount percent must be between 1 and 100",
|
||||||
"discountHoursRequired": "Specify the discount validity period in hours"
|
"discountHoursRequired": "Specify the discount validity period in hours",
|
||||||
|
"trafficRequired": "Traffic amount must be greater than 0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promoGroups": {
|
"promoGroups": {
|
||||||
@@ -3461,7 +3468,8 @@
|
|||||||
"byDate": "By date",
|
"byDate": "By date",
|
||||||
"byBalance": "By balance",
|
"byBalance": "By balance",
|
||||||
"byActivity": "By activity",
|
"byActivity": "By activity",
|
||||||
"bySpent": "By spending"
|
"bySpent": "By spending",
|
||||||
|
"byExpiry": "By expiry"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"showing": "Showing {{from}}-{{to}} of {{total}}"
|
"showing": "Showing {{from}}-{{to}} of {{total}}"
|
||||||
@@ -3651,7 +3659,11 @@
|
|||||||
"sbpCancelled": "SBP auto-payment disabled",
|
"sbpCancelled": "SBP auto-payment disabled",
|
||||||
"sbpStatus_PENDING": "Pending",
|
"sbpStatus_PENDING": "Pending",
|
||||||
"sbpStatus_ACTIVE": "Active",
|
"sbpStatus_ACTIVE": "Active",
|
||||||
"sbpStatus_PAST_DUE": "Payment failed"
|
"sbpStatus_PAST_DUE": "Payment failed",
|
||||||
|
"deleteTitle": "Delete subscription",
|
||||||
|
"deleteHint": "The subscription and its devices will be removed permanently",
|
||||||
|
"deleteButton": "Delete subscription",
|
||||||
|
"deleted": "Subscription deleted"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Current balance",
|
"current": "Current balance",
|
||||||
|
|||||||
@@ -199,6 +199,9 @@
|
|||||||
"orOpenInApp": "یا ربات را در برنامه باز کنید",
|
"orOpenInApp": "یا ربات را در برنامه باز کنید",
|
||||||
"loginFailed": "ورود ناموفق",
|
"loginFailed": "ورود ناموفق",
|
||||||
"telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:",
|
"telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:",
|
||||||
|
"deepLinkIntro": "ورود را مستقیماً در ربات تأیید کنید — بدون نیاز به شماره تلفن:",
|
||||||
|
"loginWithBot": "ورود از طریق ربات",
|
||||||
|
"backToWidget": "بازگشت به ورود با ویجت",
|
||||||
"openBotToLogin": "باز کردن ربات برای ورود",
|
"openBotToLogin": "باز کردن ربات برای ورود",
|
||||||
"waitingForConfirmation": "در انتظار تایید...",
|
"waitingForConfirmation": "در انتظار تایید...",
|
||||||
"deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.",
|
"deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.",
|
||||||
@@ -2815,7 +2818,10 @@
|
|||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"defaultTrialTariff": "تعرفه پیشفرض آزمایشی",
|
"defaultTrialTariff": "تعرفه پیشفرض آزمایشی",
|
||||||
"selectTariff": "تعرفه را انتخاب کنید",
|
"selectTariff": "تعرفه را انتخاب کنید",
|
||||||
"tariff": "تعرفه"
|
"tariff": "تعرفه",
|
||||||
|
"includeTraffic": "ترافیک",
|
||||||
|
"trafficAmount": "حجم ترافیک",
|
||||||
|
"gb": "گیگابایت"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "آمار کد تخفیف",
|
"title": "آمار کد تخفیف",
|
||||||
@@ -2887,7 +2893,8 @@
|
|||||||
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
||||||
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
||||||
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
|
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
|
||||||
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید"
|
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید",
|
||||||
|
"trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promoGroups": {
|
"promoGroups": {
|
||||||
@@ -2968,7 +2975,8 @@
|
|||||||
"byDate": "بر اساس تاریخ",
|
"byDate": "بر اساس تاریخ",
|
||||||
"byBalance": "بر اساس موجودی",
|
"byBalance": "بر اساس موجودی",
|
||||||
"byActivity": "بر اساس فعالیت",
|
"byActivity": "بر اساس فعالیت",
|
||||||
"bySpent": "بر اساس هزینه"
|
"bySpent": "بر اساس هزینه",
|
||||||
|
"byExpiry": "بر اساس انقضا"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"showing": "نمایش {{from}}-{{to}} از {{total}}"
|
"showing": "نمایش {{from}}-{{to}} از {{total}}"
|
||||||
@@ -3110,7 +3118,11 @@
|
|||||||
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
|
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
|
||||||
"sbpStatus_PENDING": "در انتظار",
|
"sbpStatus_PENDING": "در انتظار",
|
||||||
"sbpStatus_ACTIVE": "فعال",
|
"sbpStatus_ACTIVE": "فعال",
|
||||||
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
|
"sbpStatus_PAST_DUE": "پرداخت ناموفق",
|
||||||
|
"deleteTitle": "حذف اشتراک",
|
||||||
|
"deleteHint": "اشتراک و دستگاههای آن برای همیشه حذف میشوند",
|
||||||
|
"deleteButton": "حذف اشتراک",
|
||||||
|
"deleted": "اشتراک حذف شد"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "موجودی فعلی",
|
"current": "موجودی فعلی",
|
||||||
|
|||||||
@@ -210,6 +210,9 @@
|
|||||||
"orOpenInApp": "Или откройте бота в приложении",
|
"orOpenInApp": "Или откройте бота в приложении",
|
||||||
"loginFailed": "Ошибка входа",
|
"loginFailed": "Ошибка входа",
|
||||||
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
|
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
|
||||||
|
"deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:",
|
||||||
|
"loginWithBot": "Войти через бота",
|
||||||
|
"backToWidget": "Назад к входу через виджет",
|
||||||
"openBotToLogin": "Открыть бота для входа",
|
"openBotToLogin": "Открыть бота для входа",
|
||||||
"waitingForConfirmation": "Ожидание подтверждения...",
|
"waitingForConfirmation": "Ожидание подтверждения...",
|
||||||
"deepLinkExpired": "Ссылка истекла. Попробуйте снова.",
|
"deepLinkExpired": "Ссылка истекла. Попробуйте снова.",
|
||||||
@@ -3699,7 +3702,10 @@
|
|||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"defaultTrialTariff": "Тариф триала по умолчанию",
|
"defaultTrialTariff": "Тариф триала по умолчанию",
|
||||||
"selectTariff": "Выберите тариф",
|
"selectTariff": "Выберите тариф",
|
||||||
"tariff": "Тариф"
|
"tariff": "Тариф",
|
||||||
|
"includeTraffic": "Трафик",
|
||||||
|
"trafficAmount": "Объём трафика",
|
||||||
|
"gb": "ГБ"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Статистика промокода",
|
"title": "Статистика промокода",
|
||||||
@@ -3775,7 +3781,8 @@
|
|||||||
"daysRequired": "Количество дней должно быть больше 0",
|
"daysRequired": "Количество дней должно быть больше 0",
|
||||||
"groupRequired": "Выберите группу скидок",
|
"groupRequired": "Выберите группу скидок",
|
||||||
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
|
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
|
||||||
"discountHoursRequired": "Укажите время действия скидки в часах"
|
"discountHoursRequired": "Укажите время действия скидки в часах",
|
||||||
|
"trafficRequired": "Объём трафика должен быть больше 0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promoGroups": {
|
"promoGroups": {
|
||||||
@@ -3858,7 +3865,8 @@
|
|||||||
"byDate": "По дате",
|
"byDate": "По дате",
|
||||||
"byBalance": "По балансу",
|
"byBalance": "По балансу",
|
||||||
"byActivity": "По активности",
|
"byActivity": "По активности",
|
||||||
"bySpent": "По расходам"
|
"bySpent": "По расходам",
|
||||||
|
"byExpiry": "По истечению подписки"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"showing": "Показано {{from}}-{{to}} из {{total}}"
|
"showing": "Показано {{from}}-{{to}} из {{total}}"
|
||||||
@@ -4048,7 +4056,11 @@
|
|||||||
"sbpCancelled": "Автоплатёж по СБП отключён",
|
"sbpCancelled": "Автоплатёж по СБП отключён",
|
||||||
"sbpStatus_PENDING": "Ожидает подтверждения",
|
"sbpStatus_PENDING": "Ожидает подтверждения",
|
||||||
"sbpStatus_ACTIVE": "Активен",
|
"sbpStatus_ACTIVE": "Активен",
|
||||||
"sbpStatus_PAST_DUE": "Платёж не прошёл"
|
"sbpStatus_PAST_DUE": "Платёж не прошёл",
|
||||||
|
"deleteTitle": "Удаление подписки",
|
||||||
|
"deleteHint": "Подписка и её устройства будут удалены безвозвратно",
|
||||||
|
"deleteButton": "Удалить подписку",
|
||||||
|
"deleted": "Подписка удалена"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Текущий баланс",
|
"current": "Текущий баланс",
|
||||||
|
|||||||
@@ -199,6 +199,9 @@
|
|||||||
"orOpenInApp": "或在应用中打开机器人",
|
"orOpenInApp": "或在应用中打开机器人",
|
||||||
"loginFailed": "登录失败",
|
"loginFailed": "登录失败",
|
||||||
"telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:",
|
"telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:",
|
||||||
|
"deepLinkIntro": "直接在机器人中确认登录 — 无需手机号:",
|
||||||
|
"loginWithBot": "通过机器人登录",
|
||||||
|
"backToWidget": "返回小部件登录",
|
||||||
"openBotToLogin": "打开机器人登录",
|
"openBotToLogin": "打开机器人登录",
|
||||||
"waitingForConfirmation": "等待确认...",
|
"waitingForConfirmation": "等待确认...",
|
||||||
"deepLinkExpired": "链接已过期,请重试。",
|
"deepLinkExpired": "链接已过期,请重试。",
|
||||||
@@ -2814,7 +2817,10 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"defaultTrialTariff": "默认试用套餐",
|
"defaultTrialTariff": "默认试用套餐",
|
||||||
"selectTariff": "选择套餐",
|
"selectTariff": "选择套餐",
|
||||||
"tariff": "套餐"
|
"tariff": "套餐",
|
||||||
|
"includeTraffic": "流量",
|
||||||
|
"trafficAmount": "流量额度",
|
||||||
|
"gb": "GB"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "促销码统计",
|
"title": "促销码统计",
|
||||||
@@ -2886,7 +2892,8 @@
|
|||||||
"daysRequired": "天数必须大于0",
|
"daysRequired": "天数必须大于0",
|
||||||
"groupRequired": "请选择折扣组",
|
"groupRequired": "请选择折扣组",
|
||||||
"discountPercentInvalid": "折扣百分比必须在1到100之间",
|
"discountPercentInvalid": "折扣百分比必须在1到100之间",
|
||||||
"discountHoursRequired": "请指定折扣有效期(小时)"
|
"discountHoursRequired": "请指定折扣有效期(小时)",
|
||||||
|
"trafficRequired": "流量额度必须大于 0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promoGroups": {
|
"promoGroups": {
|
||||||
@@ -2967,7 +2974,8 @@
|
|||||||
"byDate": "按日期",
|
"byDate": "按日期",
|
||||||
"byBalance": "按余额",
|
"byBalance": "按余额",
|
||||||
"byActivity": "按活跃度",
|
"byActivity": "按活跃度",
|
||||||
"bySpent": "按消费"
|
"bySpent": "按消费",
|
||||||
|
"byExpiry": "按到期时间"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"showing": "显示 {{from}}-{{to}},共 {{total}}"
|
"showing": "显示 {{from}}-{{to}},共 {{total}}"
|
||||||
@@ -3109,7 +3117,11 @@
|
|||||||
"sbpCancelled": "SBP 自动扣款已关闭",
|
"sbpCancelled": "SBP 自动扣款已关闭",
|
||||||
"sbpStatus_PENDING": "待确认",
|
"sbpStatus_PENDING": "待确认",
|
||||||
"sbpStatus_ACTIVE": "已启用",
|
"sbpStatus_ACTIVE": "已启用",
|
||||||
"sbpStatus_PAST_DUE": "扣款失败"
|
"sbpStatus_PAST_DUE": "扣款失败",
|
||||||
|
"deleteTitle": "删除订阅",
|
||||||
|
"deleteHint": "订阅及其设备将被永久删除",
|
||||||
|
"deleteButton": "删除订阅",
|
||||||
|
"deleted": "订阅已删除"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "当前余额",
|
"current": "当前余额",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() {
|
|||||||
const [includeBalance, setIncludeBalance] = useState(true);
|
const [includeBalance, setIncludeBalance] = useState(true);
|
||||||
const [includeDays, setIncludeDays] = useState(false);
|
const [includeDays, setIncludeDays] = useState(false);
|
||||||
const [includeGroup, setIncludeGroup] = useState(false);
|
const [includeGroup, setIncludeGroup] = useState(false);
|
||||||
|
const [includeTraffic, setIncludeTraffic] = useState(false);
|
||||||
|
const [trafficGb, setTrafficGb] = useState<number | ''>(0);
|
||||||
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||||
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
|
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
|
||||||
const [maxUses, setMaxUses] = useState<number | ''>(1);
|
const [maxUses, setMaxUses] = useState<number | ''>(1);
|
||||||
@@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() {
|
|||||||
setMode(data.type);
|
setMode(data.type);
|
||||||
} else {
|
} else {
|
||||||
setMode('bonus_set');
|
setMode('bonus_set');
|
||||||
setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days');
|
// Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика
|
||||||
setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days');
|
// balance_and_days перестал означать «есть и баланс, и дни»: этот тип
|
||||||
|
// теперь у любого набора с трафиком, в том числе с нулевым балансом и
|
||||||
|
// нулём дней. По типу такой код открывался бы с двумя чужими галочками
|
||||||
|
// на нулях, валидация требовала бы «больше 0», и Сохранить не работало
|
||||||
|
// бы, пока админ их не снимет, — а подсказка толкает вместо этого
|
||||||
|
// вписать сумму и молча добавить коду бонус, которого в нём не было.
|
||||||
|
setIncludeBalance(data.type === 'balance' || (data.balance_bonus_rubles || 0) > 0);
|
||||||
|
setIncludeDays(data.type === 'subscription_days' || (data.subscription_days || 0) > 0);
|
||||||
// Промогруппа комбинируется с любым составом (bэкенд назначает её
|
// Промогруппа комбинируется с любым составом (bэкенд назначает её
|
||||||
// независимо от типа), поэтому чекбокс — по факту наличия группы
|
// независимо от типа), поэтому чекбокс — по факту наличия группы
|
||||||
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
|
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
|
||||||
@@ -99,6 +108,8 @@ export default function AdminPromocodeCreate() {
|
|||||||
setBalanceBonusRubles(data.balance_bonus_rubles || 0);
|
setBalanceBonusRubles(data.balance_bonus_rubles || 0);
|
||||||
}
|
}
|
||||||
setSubscriptionDays(data.subscription_days || 0);
|
setSubscriptionDays(data.subscription_days || 0);
|
||||||
|
setTrafficGb(data.traffic_gb || 0);
|
||||||
|
setIncludeTraffic((data.traffic_gb || 0) > 0);
|
||||||
setMaxUses(data.max_uses || 1);
|
setMaxUses(data.max_uses || 1);
|
||||||
setIsActive(data.is_active ?? true);
|
setIsActive(data.is_active ?? true);
|
||||||
setFirstPurchaseOnly(data.first_purchase_only || false);
|
setFirstPurchaseOnly(data.first_purchase_only || false);
|
||||||
@@ -133,7 +144,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
// едет через promo_group_id при любом типе (бэкенд применяет её независимо).
|
// едет через promo_group_id при любом типе (бэкенд применяет её независимо).
|
||||||
const derivedType: PromoCodeType =
|
const derivedType: PromoCodeType =
|
||||||
mode === 'bonus_set'
|
mode === 'bonus_set'
|
||||||
? includeBalance && includeDays
|
? includeTraffic || (includeBalance && includeDays)
|
||||||
? 'balance_and_days'
|
? 'balance_and_days'
|
||||||
: includeBalance
|
: includeBalance
|
||||||
? 'balance'
|
? 'balance'
|
||||||
@@ -148,6 +159,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
|
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
|
||||||
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
|
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
|
||||||
const maxUsesValue = maxUses === '' ? 0 : maxUses;
|
const maxUsesValue = maxUses === '' ? 0 : maxUses;
|
||||||
|
const trafficValue = trafficGb === '' ? 0 : trafficGb;
|
||||||
|
|
||||||
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
||||||
code: code.trim().toUpperCase(),
|
code: code.trim().toUpperCase(),
|
||||||
@@ -164,6 +176,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
(mode === 'bonus_set' && includeDays)
|
(mode === 'bonus_set' && includeDays)
|
||||||
? daysValue
|
? daysValue
|
||||||
: 0,
|
: 0,
|
||||||
|
traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0,
|
||||||
max_uses: maxUsesValue,
|
max_uses: maxUsesValue,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
first_purchase_only: firstPurchaseOnly,
|
first_purchase_only: firstPurchaseOnly,
|
||||||
@@ -197,7 +210,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
validationErrors.push('codeRequired');
|
validationErrors.push('codeRequired');
|
||||||
}
|
}
|
||||||
if (mode === 'bonus_set') {
|
if (mode === 'bonus_set') {
|
||||||
if (!includeBalance && !includeDays && !includeGroup) {
|
if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) {
|
||||||
validationErrors.push('bonusSetEmpty');
|
validationErrors.push('bonusSetEmpty');
|
||||||
}
|
}
|
||||||
if (includeBalance && balanceValue <= 0) {
|
if (includeBalance && balanceValue <= 0) {
|
||||||
@@ -209,6 +222,9 @@ export default function AdminPromocodeCreate() {
|
|||||||
if (includeGroup && !promoGroupId) {
|
if (includeGroup && !promoGroupId) {
|
||||||
validationErrors.push('groupRequired');
|
validationErrors.push('groupRequired');
|
||||||
}
|
}
|
||||||
|
if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) {
|
||||||
|
validationErrors.push('trafficRequired');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mode === 'trial_subscription' && daysValue <= 0) {
|
if (mode === 'trial_subscription' && daysValue <= 0) {
|
||||||
validationErrors.push('daysRequired');
|
validationErrors.push('daysRequired');
|
||||||
@@ -308,6 +324,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
[
|
[
|
||||||
['includeBalance', includeBalance, setIncludeBalance] as const,
|
['includeBalance', includeBalance, setIncludeBalance] as const,
|
||||||
['includeDays', includeDays, setIncludeDays] as const,
|
['includeDays', includeDays, setIncludeDays] as const,
|
||||||
|
['includeTraffic', includeTraffic, setIncludeTraffic] as const,
|
||||||
['includePromoGroup', includeGroup, setIncludeGroup] as const,
|
['includePromoGroup', includeGroup, setIncludeGroup] as const,
|
||||||
] as const
|
] as const
|
||||||
).map(([key, checked, setChecked]) => (
|
).map(([key, checked, setChecked]) => (
|
||||||
@@ -361,6 +378,28 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === 'bonus_set' && includeTraffic && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pc-traffic-gb" className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.promocodes.form.trafficAmount')}
|
||||||
|
<span className="text-error-400">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="pc-traffic-gb"
|
||||||
|
type="number"
|
||||||
|
value={trafficGb}
|
||||||
|
onChange={createNumberInputHandler(setTrafficGb, 0)}
|
||||||
|
className="input w-32"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">{t('admin.promocodes.form.gb')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
|
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
|
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
|||||||
@@ -196,16 +196,27 @@ export default function AdminPromocodes() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Info line */}
|
{/* Info line */}
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||||
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
|
{/* Составляющие показываем по значению, а не по типу:
|
||||||
<span className="text-success-400">
|
balance_and_days теперь стоит и у набора, где баланса
|
||||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
или дней нет вовсе, — иначе в списке висело бы «+0 ₽
|
||||||
</span>
|
+0 дн.» у кода, который на самом деле раздаёт трафик. */}
|
||||||
)}
|
{(promo.type === 'balance' || promo.type === 'balance_and_days') &&
|
||||||
|
promo.balance_bonus_rubles > 0 && (
|
||||||
|
<span className="text-success-400">
|
||||||
|
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(promo.type === 'subscription_days' ||
|
{(promo.type === 'subscription_days' ||
|
||||||
promo.type === 'trial_subscription' ||
|
promo.type === 'trial_subscription' ||
|
||||||
promo.type === 'balance_and_days') && (
|
promo.type === 'balance_and_days') &&
|
||||||
|
promo.subscription_days > 0 && (
|
||||||
|
<span className="text-accent-400">
|
||||||
|
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && (
|
||||||
<span className="text-accent-400">
|
<span className="text-accent-400">
|
||||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
+{promo.traffic_gb} {t('admin.promocodes.form.gb')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{promo.type === 'discount' && (
|
{promo.type === 'discount' && (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
|
|||||||
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
||||||
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
||||||
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import { toNumber } from '../utils/inputHelpers';
|
import { toNumber } from '../utils/inputHelpers';
|
||||||
import { usePermissionStore } from '../store/permissions';
|
import { usePermissionStore } from '../store/permissions';
|
||||||
|
|
||||||
@@ -662,6 +663,28 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSubscription = async () => {
|
||||||
|
if (!userId || !selectedSub) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
// Активную платную подписку сервер по умолчанию бережёт — админ уже
|
||||||
|
// подтвердил намерение кнопкой, поэтому просим удалить именно её.
|
||||||
|
const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial;
|
||||||
|
await adminUsersApi.deleteSubscription(userId, selectedSub.id, force);
|
||||||
|
notify.success(t('admin.users.detail.subscription.deleted'), t('common.success'));
|
||||||
|
setSubscriptionDetailView(false);
|
||||||
|
await loadUser();
|
||||||
|
} catch (err) {
|
||||||
|
// Отказы тут осмысленные и действенные: открытый временный доступ
|
||||||
|
// (409, «сначала заверши или восстанови grace»), активная платная без
|
||||||
|
// force (409), подписки нет (404). Общее «Ошибка» оставило бы админа
|
||||||
|
// гадать, почему кнопка не сработала, — показываем текст сервера.
|
||||||
|
notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDisableUser = async () => {
|
const handleDisableUser = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -874,6 +897,7 @@ export default function AdminUserDetail() {
|
|||||||
userSubscriptions={userSubscriptions}
|
userSubscriptions={userSubscriptions}
|
||||||
selectedSub={selectedSub}
|
selectedSub={selectedSub}
|
||||||
onCancelSbpRecurring={handleCancelSbpRecurring}
|
onCancelSbpRecurring={handleCancelSbpRecurring}
|
||||||
|
onDeleteSubscription={handleDeleteSubscription}
|
||||||
activeSubscriptionId={activeSubscriptionId}
|
activeSubscriptionId={activeSubscriptionId}
|
||||||
onActiveSubscriptionChange={setActiveSubscriptionId}
|
onActiveSubscriptionChange={setActiveSubscriptionId}
|
||||||
subscriptionDetailView={subscriptionDetailView}
|
subscriptionDetailView={subscriptionDetailView}
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export default function AdminUsers() {
|
|||||||
<option value="balance">{t('admin.users.filters.byBalance')}</option>
|
<option value="balance">{t('admin.users.filters.byBalance')}</option>
|
||||||
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
|
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
|
||||||
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
|
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
|
||||||
|
<option value="subscription_end_date">{t('admin.users.filters.byExpiry')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PromoOffersSection from '../components/PromoOffersSection';
|
|||||||
import NewsSection from '../components/news/NewsSection';
|
import NewsSection from '../components/news/NewsSection';
|
||||||
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
||||||
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||||
|
import ConnectDeviceTile from '../components/dashboard/ConnectDeviceTile';
|
||||||
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||||
import StatsGrid from '../components/dashboard/StatsGrid';
|
import StatsGrid from '../components/dashboard/StatsGrid';
|
||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
@@ -79,6 +80,24 @@ export default function Dashboard() {
|
|||||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Плитка «Подключить устройство» на главной живёт в МУЛЬТИТАРИФНОЙ ветке, а
|
||||||
|
// запрос выше там выключен: он привязан к одиночной подписке (`subscription`
|
||||||
|
// в мультитарифе всегда null). Без отдельного запроса счётчик плитки всегда
|
||||||
|
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
|
||||||
|
// есть ровно то, ради чего плитку и добавили, не работало бы.
|
||||||
|
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
|
||||||
|
const homeSingleSub =
|
||||||
|
isMultiTariff && multiSubData?.subscriptions?.length === 1
|
||||||
|
? multiSubData.subscriptions[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { data: homeSingleSubDevices } = useQuery({
|
||||||
|
queryKey: ['devices', homeSingleSub?.id],
|
||||||
|
queryFn: () => subscriptionApi.getDevices(homeSingleSub?.id),
|
||||||
|
enabled: !!homeSingleSub,
|
||||||
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
queryFn: referralApi.getReferralInfo,
|
queryFn: referralApi.getReferralInfo,
|
||||||
@@ -301,6 +320,26 @@ export default function Dashboard() {
|
|||||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Подписку мог выдать бонус рекламной кампании — она создаётся сама,
|
||||||
|
и человек попадает на главную с готовым доступом. Пока подписка
|
||||||
|
одна, показываем здесь же, как подключить устройство: иначе за
|
||||||
|
этим нужно уходить на отдельную страницу, о чём он не догадается. */}
|
||||||
|
{homeSingleSub && (
|
||||||
|
<ConnectDeviceTile
|
||||||
|
subscription={homeSingleSub}
|
||||||
|
connectedDevices={homeSingleSubDevices?.total ?? 0}
|
||||||
|
usedPercent={
|
||||||
|
homeSingleSub.traffic_limit_gb > 0
|
||||||
|
? Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
(homeSingleSub.traffic_used_gb / homeSingleSub.traffic_limit_gb) * 100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{multiSubData.subscriptions.length > 3 && (
|
{multiSubData.subscriptions.length > 3 && (
|
||||||
<Link
|
<Link
|
||||||
to="/subscriptions"
|
to="/subscriptions"
|
||||||
|
|||||||
Reference in New Issue
Block a user