mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
feat(dashboard): подключение устройства прикреплено к карточке подписки
На главной подключение жило отдельной плиткой под карточкой, и показывалось только когда подписка одна: при нескольких «выбор устройства неоднозначен». Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и покупка новой, — из которых вторая ровней не была. При нескольких подписках подключение с главной пропадало вовсе. Теперь это подвал самой карточки: тело ведёт в подписку, подвал — в подключение. Одна карточка на подписку при любом их числе, отдельных плиток нет. Карточка снаружи перестала быть `<button>` — вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо, поэтому это обёртка с двумя зонами нажатия. Подвал включается пропом `connect` и задан только на главной: список «Все подписки» остаётся перечнем без действий. Где подвал есть, из строки статистики убран счётчик лимита устройств — он дублировал бы счётчик подвала. Акцентом подсвечивается только подписка без единого устройства: там действие и нужно. У остальных подвал приглушён и не спорит с карточкой. Исчерпанный лимит не блокирует кнопку, а ведёт в разбор: сколько слотов даёт тариф, какие устройства их занимают, отключить лишнее либо добавить слоты. Докупка скрыта на тестовой подписке и при безлимите — те же условия, что у блока докупки на странице подписки, — и тогда текст не обещает того, чего сделать нельзя. Пока счётчик устройств не пришёл, подвал показывает скелетон, а не догадку о состоянии. Его высоту задаёт невидимый спан с той же типографикой: подбор числом даёт дробные пиксели строчного бокса, и карточка дёргается. Проверено замером — сдвиг 0px на всех состояниях. ResponsiveSheet: на телефоне нижний шит, с 640px обычное центрированное окно. Шит на десктопе — плашка у нижнего края с ручкой перетаскивания, бессмысленной для мыши. Компонент общий, остальные шиты кабинета смогут его переиспользовать. ConnectDeviceTile не тронут и продолжает жить в карточке подписки одиночного тарифа вместе с якорем онбординга. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
139
src/components/subscription/DeviceLimitSheet.tsx
Normal file
139
src/components/subscription/DeviceLimitSheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { subscriptionApi } from '@/api/subscription';
|
||||
import { DevicesIcon, PlusIcon, TrashIcon, WarningIcon } from '@/components/icons';
|
||||
import { ResponsiveSheet } from '@/components/ui/ResponsiveSheet';
|
||||
import { getApiErrorMessage } from '@/utils/api-error';
|
||||
|
||||
interface DeviceLimitSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subscriptionId: number;
|
||||
subscriptionName: string;
|
||||
deviceLimit: number;
|
||||
isTrial: boolean;
|
||||
devices: Array<{
|
||||
hwid: string;
|
||||
platform: string;
|
||||
device_model: string;
|
||||
local_name?: string | null;
|
||||
}>;
|
||||
/** Уводит на страницу подписки, где живёт докупка слотов. */
|
||||
onOpenSubscription: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Почему нельзя подключить ещё одно устройство и что с этим делать.
|
||||
*
|
||||
* Открывается из подвала карточки, когда слоты кончились. Заблокировать
|
||||
* кнопку было бы проще, но человек остался бы без выхода: реальных действий
|
||||
* тут два — освободить слот или добавить ещё.
|
||||
*/
|
||||
export function DeviceLimitSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
subscriptionId,
|
||||
subscriptionName,
|
||||
deviceLimit,
|
||||
isTrial,
|
||||
devices,
|
||||
onOpenSubscription,
|
||||
}: DeviceLimitSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Докупка слотов недоступна на тестовой подписке и при безлимите — ровно
|
||||
// те же условия, что у блока докупки на странице подписки.
|
||||
const canAddSlots = !isTrial && deviceLimit !== 0;
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(getApiErrorMessage(err, t('common.error', 'Не удалось выполнить действие'))),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('subscription.connectFooter.full', 'Все слоты заняты')}
|
||||
>
|
||||
<div className="space-y-4 px-4 pb-6 sm:px-5">
|
||||
<div className="flex items-start gap-3 rounded-2xl bg-warning-400/10 p-3.5">
|
||||
<WarningIcon className="mt-0.5 h-5 w-5 shrink-0 text-warning-400" />
|
||||
<p className="text-[13px] leading-snug text-dark-200">
|
||||
{canAddSlots
|
||||
? t('subscription.connectFooter.limitExplained', {
|
||||
defaultValue:
|
||||
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.',
|
||||
name: subscriptionName,
|
||||
count: deviceLimit,
|
||||
})
|
||||
: t('subscription.connectFooter.limitExplainedNoTopup', {
|
||||
defaultValue:
|
||||
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.',
|
||||
name: subscriptionName,
|
||||
count: deviceLimit,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-dark-500">
|
||||
{t('subscription.connectFooter.connectedDevices', 'Подключённые устройства')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.hwid}
|
||||
className="flex items-center gap-3 rounded-xl bg-dark-800/60 px-3 py-2.5"
|
||||
>
|
||||
<DevicesIcon className="h-4 w-4 shrink-0 opacity-40" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-dark-100">
|
||||
{device.local_name || device.device_model || device.platform}
|
||||
</div>
|
||||
<div className="text-[11px] text-dark-500">{device.platform}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate(device.hwid)}
|
||||
className="flex min-h-[36px] shrink-0 items-center gap-1.5 rounded-lg bg-error-500/10 px-2.5 text-[12px] font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
{t('subscription.connectFooter.disconnect', 'Отключить')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-xl bg-error-500/10 px-3 py-2 text-[12px] text-error-400">{error}</p>
|
||||
)}
|
||||
|
||||
{canAddSlots ? (
|
||||
<button type="button" onClick={onOpenSubscription} className="btn-primary w-full">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t('subscription.connectFooter.addSlots', 'Добавить слоты')}
|
||||
</button>
|
||||
) : (
|
||||
<p className="rounded-xl bg-dark-800/60 px-3 py-2.5 text-[12px] leading-snug text-dark-400">
|
||||
{t(
|
||||
'subscription.connectFooter.noTopupHint',
|
||||
'На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
93
src/components/subscription/SubscriptionConnectFooter.tsx
Normal file
93
src/components/subscription/SubscriptionConnectFooter.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRightIcon, DevicesIcon, WarningIcon } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConnectFooterState } from './connectFooterState';
|
||||
|
||||
interface SubscriptionConnectFooterProps {
|
||||
state: ConnectFooterState;
|
||||
/** Цвет разделителя берём у карточки, чтобы подвал читался её частью. */
|
||||
borderColor: string;
|
||||
mutedColor: string;
|
||||
onConnect: () => void;
|
||||
onManage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подвал карточки подписки: подключить устройство либо разобраться с лимитом.
|
||||
*
|
||||
* Отдельная зона нажатия внутри карточки, поэтому карточка снаружи — не
|
||||
* `<button>`, а обёртка: вложенные кнопки невалидны и ломаются в браузере.
|
||||
*/
|
||||
export function SubscriptionConnectFooter({
|
||||
state,
|
||||
borderColor,
|
||||
mutedColor,
|
||||
onConnect,
|
||||
onManage,
|
||||
}: SubscriptionConnectFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.kind === 'hidden') return null;
|
||||
|
||||
if (state.kind === 'loading') {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 border-t px-4 py-2.5"
|
||||
style={{ borderColor }}
|
||||
aria-busy="true"
|
||||
>
|
||||
{/* Высоту задаёт невидимый текст с той же типографикой, что у готового
|
||||
подвала. Подбирать её числом нельзя: строчный бокс даёт дробные
|
||||
пиксели, и карточка дёргается при появлении данных. */}
|
||||
<div className="flex w-full items-center gap-2.5">
|
||||
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-dark-700/60" />
|
||||
<div className="h-3.5 w-40 animate-pulse rounded bg-dark-700/60" />
|
||||
<div className="ml-auto h-3 w-10 animate-pulse rounded bg-dark-700/60" />
|
||||
<span aria-hidden className="w-0 overflow-hidden text-[13px] font-medium">
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFull = state.kind === 'full';
|
||||
const highlight = state.kind === 'connect' && state.highlight;
|
||||
|
||||
const counter =
|
||||
state.kind === 'connect' && state.unlimited
|
||||
? `${state.used} · ∞`
|
||||
: `${state.used} / ${state.limit}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={isFull ? onManage : onConnect}
|
||||
className="flex w-full items-center gap-2.5 border-t px-4 py-2.5 text-left transition-colors active:bg-accent-500/10"
|
||||
style={{ borderColor }}
|
||||
>
|
||||
{isFull ? (
|
||||
<WarningIcon className="h-4 w-4 shrink-0 text-warning-400/80" />
|
||||
) : (
|
||||
<DevicesIcon
|
||||
className={cn('h-4 w-4 shrink-0', highlight ? 'text-accent-400' : 'opacity-40')}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('text-[13px] font-medium', highlight && 'text-accent-400')}
|
||||
style={highlight ? undefined : { color: mutedColor }}
|
||||
>
|
||||
{isFull
|
||||
? t('subscription.connectFooter.full', 'Все слоты заняты')
|
||||
: t('subscription.connectFooter.connect', 'Подключить устройство')}
|
||||
</span>
|
||||
<span
|
||||
className={cn('ml-auto text-[11px] tabular-nums', isFull && 'text-warning-400/80')}
|
||||
style={isFull ? undefined : { color: mutedColor }}
|
||||
>
|
||||
{counter}
|
||||
</span>
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 opacity-30" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { useHaptic } from '../../platform';
|
||||
import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons';
|
||||
import type { SubscriptionListItem } from '../../types';
|
||||
import { connectFooterState } from './connectFooterState';
|
||||
import { SubscriptionConnectFooter } from './SubscriptionConnectFooter';
|
||||
|
||||
function formatDate(iso: string | null, locale?: string): string {
|
||||
if (!iso) return '—';
|
||||
@@ -68,9 +70,20 @@ function StatusBadge({
|
||||
export default function SubscriptionListCard({
|
||||
subscription,
|
||||
onClick,
|
||||
connect,
|
||||
}: {
|
||||
subscription: SubscriptionListItem;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Подключение устройства прямо из карточки. Задаётся только на главной:
|
||||
* список «Все подписки» остаётся простым перечнем без действий.
|
||||
*/
|
||||
connect?: {
|
||||
/** `undefined`, пока число устройств не загрузилось. */
|
||||
connectedDevices: number | undefined;
|
||||
onConnect: () => void;
|
||||
onManage: () => void;
|
||||
};
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark } = useTheme();
|
||||
@@ -123,89 +136,113 @@ export default function SubscriptionListCard({
|
||||
: 'rgba(255,59,92,0.03)'
|
||||
: g.cardBg;
|
||||
|
||||
const footer = connect
|
||||
? connectFooterState({
|
||||
status: subscription.status,
|
||||
subscriptionUrl: subscription.subscription_url,
|
||||
deviceLimit: subscription.device_limit,
|
||||
connected: connect.connectedDevices,
|
||||
})
|
||||
: { kind: 'hidden' as const };
|
||||
|
||||
// Подвал — своя зона нажатия, поэтому карточка снаружи не `<button>`:
|
||||
// вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо.
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
|
||||
style={{ background: bgColor, borderColor }}
|
||||
>
|
||||
{/* Header: tariff name + status badge + chevron */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
|
||||
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
|
||||
</div>
|
||||
|
||||
{/* Traffic mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
|
||||
{t('subscription.traffic', 'Трафик')}
|
||||
</span>
|
||||
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
|
||||
{isUnlimited
|
||||
? '∞'
|
||||
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
|
||||
<button onClick={handleClick} className="w-full p-4 text-left">
|
||||
{/* Header: tariff name + status badge + chevron */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
|
||||
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${trafficColor}`}
|
||||
style={{ width: `${Math.max(1, trafficPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-4 text-[12px]"
|
||||
style={{ color: g.textSecondary }}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{formatDate(subscription.end_date, i18n.language)}
|
||||
</span>
|
||||
{!isTrial &&
|
||||
(() => {
|
||||
const isDaily = subscription.is_daily;
|
||||
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
|
||||
const label = isDaily
|
||||
? t('subscription.dailyAutoCharge', 'Автосписание')
|
||||
: t('subscription.autopay', 'Автопродление');
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
|
||||
>
|
||||
{enabled ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
{/* Traffic mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
|
||||
{t('subscription.traffic', 'Трафик')}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
|
||||
{isUnlimited
|
||||
? '∞'
|
||||
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${trafficColor}`}
|
||||
style={{ width: `${Math.max(1, trafficPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-4 text-[12px]"
|
||||
style={{ color: g.textSecondary }}
|
||||
>
|
||||
{footer.kind === 'hidden' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{formatDate(subscription.end_date, i18n.language)}
|
||||
</span>
|
||||
{!isTrial &&
|
||||
(() => {
|
||||
const isDaily = subscription.is_daily;
|
||||
const enabled = isDaily
|
||||
? !subscription.is_daily_paused
|
||||
: subscription.autopay_enabled;
|
||||
const label = isDaily
|
||||
? t('subscription.dailyAutoCharge', 'Автосписание')
|
||||
: t('subscription.autopay', 'Автопродление');
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
|
||||
>
|
||||
{enabled ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<SubscriptionConnectFooter
|
||||
state={footer}
|
||||
borderColor={borderColor}
|
||||
mutedColor={g.textSecondary}
|
||||
onConnect={() => connect?.onConnect()}
|
||||
onManage={() => connect?.onManage()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/components/subscription/connectFooterState.test.ts
Normal file
70
src/components/subscription/connectFooterState.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { connectFooterState } from './connectFooterState';
|
||||
|
||||
const base = {
|
||||
status: 'active',
|
||||
subscriptionUrl: 'https://example.invalid/sub',
|
||||
deviceLimit: 5,
|
||||
connected: 2,
|
||||
};
|
||||
|
||||
describe('connectFooterState', () => {
|
||||
it('обычная подписка со свободными слотами зовёт подключить', () => {
|
||||
expect(connectFooterState(base)).toEqual({
|
||||
kind: 'connect',
|
||||
used: 2,
|
||||
limit: 5,
|
||||
unlimited: false,
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('подсвечивает подписку без единого устройства — только там действие и нужно', () => {
|
||||
const state = connectFooterState({ ...base, connected: 0 });
|
||||
expect(state).toMatchObject({ kind: 'connect', highlight: true });
|
||||
});
|
||||
|
||||
it('исчерпанный лимит — это не «нельзя», а повод управлять устройствами', () => {
|
||||
expect(connectFooterState({ ...base, connected: 5 })).toEqual({
|
||||
kind: 'full',
|
||||
used: 5,
|
||||
limit: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('устройств больше лимита (лимит понизили) — тоже полный', () => {
|
||||
expect(connectFooterState({ ...base, connected: 7 })).toMatchObject({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('нулевой лимит означает безлимит, а не запрет', () => {
|
||||
expect(connectFooterState({ ...base, deviceLimit: 0, connected: 12 })).toEqual({
|
||||
kind: 'connect',
|
||||
used: 12,
|
||||
limit: 0,
|
||||
unlimited: true,
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('пока счётчик не пришёл — загрузка, а не догадка о состоянии', () => {
|
||||
expect(connectFooterState({ ...base, connected: undefined })).toEqual({ kind: 'loading' });
|
||||
});
|
||||
|
||||
it.each(['expired', 'disabled'])('у подписки со статусом %s подключать нечего', (status) => {
|
||||
expect(connectFooterState({ ...base, status })).toEqual({ kind: 'hidden' });
|
||||
});
|
||||
|
||||
it.each(['active', 'trial', 'limited'])('статус %s подвал показывает', (status) => {
|
||||
expect(connectFooterState({ ...base, status }).kind).toBe('connect');
|
||||
});
|
||||
|
||||
it.each([null, undefined, ''])('без ссылки на подписку (%s) подвала нет', (subscriptionUrl) => {
|
||||
expect(connectFooterState({ ...base, subscriptionUrl })).toEqual({ kind: 'hidden' });
|
||||
});
|
||||
|
||||
it('отсутствие ссылки важнее незагруженного счётчика', () => {
|
||||
expect(connectFooterState({ ...base, subscriptionUrl: null, connected: undefined })).toEqual({
|
||||
kind: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/components/subscription/connectFooterState.ts
Normal file
61
src/components/subscription/connectFooterState.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Состояние подвала карточки подписки — «подключить устройство».
|
||||
*
|
||||
* Вынесено из компонента отдельно, потому что правил тут больше, чем кажется:
|
||||
* безлимит и исчерпанный лимит выражаются одним и тем же полем `device_limit`,
|
||||
* а незагруженный счётчик нельзя путать с нулём подключённых устройств.
|
||||
*/
|
||||
|
||||
export type ConnectFooterState =
|
||||
/** Подключать нечего: подписка истекла или у неё нет ссылки. */
|
||||
| { kind: 'hidden' }
|
||||
/** Счётчик устройств ещё не пришёл — показываем скелетон, а не догадку. */
|
||||
| { kind: 'loading' }
|
||||
/** Есть куда подключаться. `highlight` — ни одного устройства, зовём заметнее. */
|
||||
| { kind: 'connect'; used: number; limit: number; unlimited: boolean; highlight: boolean }
|
||||
/** Слоты кончились — ведём разбираться, а не блокируем. */
|
||||
| { kind: 'full'; used: number; limit: number };
|
||||
|
||||
/** Статусы, при которых доступ ещё работает и устройство есть смысл подключать. */
|
||||
const CONNECTABLE_STATUSES = new Set(['active', 'trial', 'limited']);
|
||||
|
||||
export interface ConnectFooterInput {
|
||||
status: string;
|
||||
/** Ссылка на подписку из панели: без неё подключать не к чему. */
|
||||
subscriptionUrl: string | null | undefined;
|
||||
/** 0 означает «без лимита устройств», а не «нельзя ни одного». */
|
||||
deviceLimit: number;
|
||||
/** `undefined`, пока запрос числа устройств не завершился. */
|
||||
connected: number | undefined;
|
||||
}
|
||||
|
||||
export function connectFooterState({
|
||||
status,
|
||||
subscriptionUrl,
|
||||
deviceLimit,
|
||||
connected,
|
||||
}: ConnectFooterInput): ConnectFooterState {
|
||||
if (!subscriptionUrl || !CONNECTABLE_STATUSES.has(status)) {
|
||||
return { kind: 'hidden' };
|
||||
}
|
||||
|
||||
if (connected === undefined) {
|
||||
return { kind: 'loading' };
|
||||
}
|
||||
|
||||
const unlimited = deviceLimit === 0;
|
||||
|
||||
// `>=`, а не `===`: лимит можно понизить ниже числа уже подключённых
|
||||
// устройств, и тогда слотов «минус один» — состояние всё равно полное.
|
||||
if (!unlimited && connected >= deviceLimit) {
|
||||
return { kind: 'full', used: connected, limit: deviceLimit };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'connect',
|
||||
used: connected,
|
||||
limit: deviceLimit,
|
||||
unlimited,
|
||||
highlight: connected === 0,
|
||||
};
|
||||
}
|
||||
90
src/components/ui/ResponsiveSheet.tsx
Normal file
90
src/components/ui/ResponsiveSheet.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { XCloseIcon } from '@/components/icons';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
import { Sheet } from './Sheet';
|
||||
|
||||
/** Ширина, с которой нижний шит перестаёт быть уместным. */
|
||||
const DESKTOP_QUERY = '(min-width: 640px)';
|
||||
|
||||
function useIsDesktop(): boolean {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(DESKTOP_QUERY).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(DESKTOP_QUERY);
|
||||
const onChange = (event: MediaQueryListEvent) => setIsDesktop(event.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
setIsDesktop(query.matches);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
interface ResponsiveSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижний шит на телефоне, обычное окно на десктопе.
|
||||
*
|
||||
* Шит — мобильная идиома: ручка перетаскивания мышью бессмысленна, а
|
||||
* прижатая к нижнему краю плашка на широком экране оставляет центр пустым
|
||||
* и обрезает нижние скругления. На десктопе то же содержимое показывается
|
||||
* центрированным окном с ловушкой фокуса и закрытием по Escape.
|
||||
*/
|
||||
export function ResponsiveSheet({ isOpen, onClose, title, children }: ResponsiveSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDesktop = useIsDesktop();
|
||||
// Ловушка нужна только своей ветке: у Sheet она уже своя.
|
||||
const dialogRef = useFocusTrap<HTMLDivElement>(isOpen && isDesktop, { onEscape: onClose });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<Sheet isOpen onClose={onClose} title={title}>
|
||||
{children}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-dark-950/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
|
||||
<h3 className="text-lg font-semibold text-dark-50">{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', 'Закрыть')}
|
||||
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||
>
|
||||
<XCloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -739,6 +739,16 @@
|
||||
"confirmDelete": "Delete subscription?",
|
||||
"dailyAutoCharge": "Daily auto-charge",
|
||||
"defaultName": "Subscription",
|
||||
"connectFooter": {
|
||||
"connect": "Connect a device",
|
||||
"full": "All slots are in use",
|
||||
"connectedDevices": "Connected devices",
|
||||
"disconnect": "Disconnect",
|
||||
"addSlots": "Add slots",
|
||||
"limitExplained": "The «{{name}}» plan gives {{count}} slots and all are in use. Free one up or add more.",
|
||||
"limitExplainedNoTopup": "The «{{name}}» plan gives {{count}} slots and all are in use. Free up a slot to continue.",
|
||||
"noTopupHint": "Slots cannot be bought on a trial subscription — free one up or switch to a paid plan."
|
||||
},
|
||||
"delete": "Delete",
|
||||
"deleteTitle": "Delete subscription",
|
||||
"get_config": "Get config",
|
||||
|
||||
@@ -644,6 +644,16 @@
|
||||
},
|
||||
"dailyAutoCharge": "برداشت خودکار روزانه",
|
||||
"defaultName": "اشتراک",
|
||||
"connectFooter": {
|
||||
"connect": "اتصال دستگاه",
|
||||
"full": "همه ظرفیتها پر است",
|
||||
"connectedDevices": "دستگاههای متصل",
|
||||
"disconnect": "قطع اتصال",
|
||||
"addSlots": "افزودن ظرفیت",
|
||||
"limitExplained": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یکی را آزاد کنید یا ظرفیت اضافه کنید.",
|
||||
"limitExplainedNoTopup": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یک ظرفیت را آزاد کنید.",
|
||||
"noTopupHint": "روی اشتراک آزمایشی نمیتوان ظرفیت خرید — یکی را آزاد کنید یا طرح پولی بگیرید."
|
||||
},
|
||||
"delete": "حذف",
|
||||
"deleteTitle": "حذف اشتراک",
|
||||
"get_config": "دریافت پیکربندی",
|
||||
|
||||
@@ -758,6 +758,16 @@
|
||||
"confirmDelete": "Удалить подписку?",
|
||||
"dailyAutoCharge": "Ежедневное списание",
|
||||
"defaultName": "Подписка",
|
||||
"connectFooter": {
|
||||
"connect": "Подключить устройство",
|
||||
"full": "Все слоты заняты",
|
||||
"connectedDevices": "Подключённые устройства",
|
||||
"disconnect": "Отключить",
|
||||
"addSlots": "Добавить слоты",
|
||||
"limitExplained": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.",
|
||||
"limitExplainedNoTopup": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.",
|
||||
"noTopupHint": "На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф."
|
||||
},
|
||||
"delete": "Удалить",
|
||||
"deleteTitle": "Удаление подписки",
|
||||
"get_config": "Получить конфиг",
|
||||
|
||||
@@ -644,6 +644,16 @@
|
||||
},
|
||||
"dailyAutoCharge": "每日自动扣费",
|
||||
"defaultName": "订阅",
|
||||
"connectFooter": {
|
||||
"connect": "连接设备",
|
||||
"full": "所有名额已占满",
|
||||
"connectedDevices": "已连接设备",
|
||||
"disconnect": "断开",
|
||||
"addSlots": "增加名额",
|
||||
"limitExplained": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请释放一个名额或增加名额。",
|
||||
"limitExplainedNoTopup": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请先释放一个名额。",
|
||||
"noTopupHint": "试用订阅无法购买名额 — 请释放已占用的名额或改用付费套餐。"
|
||||
},
|
||||
"delete": "删除",
|
||||
"deleteTitle": "删除订阅",
|
||||
"get_config": "获取配置",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
@@ -14,13 +14,13 @@ import PromoOffersSection from '../components/PromoOffersSection';
|
||||
import NewsSection from '../components/news/NewsSection';
|
||||
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
||||
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||
import ConnectDeviceTile from '../components/dashboard/ConnectDeviceTile';
|
||||
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||
import StatsGrid from '../components/dashboard/StatsGrid';
|
||||
import { giftApi } from '../api/gift';
|
||||
import { promoApi } from '../api/promo';
|
||||
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
||||
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||
import { DeviceLimitSheet } from '../components/subscription/DeviceLimitSheet';
|
||||
import { API } from '../config/constants';
|
||||
import { ChevronRightIcon, StarIcon } from '@/components/icons';
|
||||
|
||||
@@ -86,18 +86,29 @@ export default function Dashboard() {
|
||||
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
|
||||
// есть ровно то, ради чего плитку и добавили, не работало бы.
|
||||
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
|
||||
const homeSingleSub =
|
||||
isMultiTariff && multiSubData?.subscriptions?.length === 1
|
||||
? multiSubData.subscriptions[0]
|
||||
: null;
|
||||
// Карточки подписок на главной показывают, сколько устройств подключено, и
|
||||
// дают подключить ещё. Число устройств живёт в панели, поэтому запрос идёт
|
||||
// на каждую показанную подписку; ключ ['devices', id] тот же, что на
|
||||
// странице подписки, так что кэш общий и переход туда не стоит сети.
|
||||
const visibleSubscriptions = useMemo(
|
||||
() => multiSubData?.subscriptions?.slice(0, 3) ?? [],
|
||||
[multiSubData],
|
||||
);
|
||||
|
||||
const { data: homeSingleSubDevices } = useQuery({
|
||||
queryKey: ['devices', homeSingleSub?.id],
|
||||
queryFn: () => subscriptionApi.getDevices(homeSingleSub?.id),
|
||||
enabled: !!homeSingleSub,
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
const deviceQueries = useQueries({
|
||||
queries: visibleSubscriptions.map((sub) => ({
|
||||
queryKey: ['devices', sub.id],
|
||||
queryFn: () => subscriptionApi.getDevices(sub.id),
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
})),
|
||||
});
|
||||
|
||||
// Подписка, у которой разбираем исчерпанный лимит устройств.
|
||||
const [deviceLimitSubId, setDeviceLimitSubId] = useState<number | null>(null);
|
||||
const deviceLimitSub = visibleSubscriptions.find((s) => s.id === deviceLimitSubId) ?? null;
|
||||
const deviceLimitDevices =
|
||||
deviceQueries[visibleSubscriptions.findIndex((s) => s.id === deviceLimitSubId)]?.data;
|
||||
|
||||
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
queryFn: referralApi.getReferralInfo,
|
||||
@@ -313,33 +324,18 @@ export default function Dashboard() {
|
||||
{t('dashboard.manageAll', 'Управление')} →
|
||||
</Link>
|
||||
</div>
|
||||
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
|
||||
{visibleSubscriptions.map((sub, index) => (
|
||||
<SubscriptionListCard
|
||||
key={sub.id}
|
||||
subscription={sub}
|
||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||
connect={{
|
||||
connectedDevices: deviceQueries[index]?.data?.total,
|
||||
onConnect: () => navigate(`/connection?sub=${sub.id}`),
|
||||
onManage: () => setDeviceLimitSubId(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 && (
|
||||
<Link
|
||||
to="/subscriptions"
|
||||
@@ -465,6 +461,22 @@ export default function Dashboard() {
|
||||
onSkip={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deviceLimitSub && (
|
||||
<DeviceLimitSheet
|
||||
isOpen
|
||||
onClose={() => setDeviceLimitSubId(null)}
|
||||
subscriptionId={deviceLimitSub.id}
|
||||
subscriptionName={deviceLimitSub.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
deviceLimit={deviceLimitSub.device_limit}
|
||||
isTrial={deviceLimitSub.is_trial}
|
||||
devices={deviceLimitDevices?.devices ?? []}
|
||||
onOpenSubscription={() => {
|
||||
setDeviceLimitSubId(null);
|
||||
navigate(`/subscriptions/${deviceLimitSub.id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user