diff --git a/src/components/subscription/DeviceLimitSheet.tsx b/src/components/subscription/DeviceLimitSheet.tsx new file mode 100644 index 0000000..23ee82c --- /dev/null +++ b/src/components/subscription/DeviceLimitSheet.tsx @@ -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(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 ( + +
+
+ +

+ {canAddSlots + ? t('subscription.connectFooter.limitExplained', { + defaultValue: + 'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.', + name: subscriptionName, + count: deviceLimit, + }) + : t('subscription.connectFooter.limitExplainedNoTopup', { + defaultValue: + 'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.', + name: subscriptionName, + count: deviceLimit, + })} +

+
+ +
+
+ {t('subscription.connectFooter.connectedDevices', 'Подключённые устройства')} +
+
+ {devices.map((device) => ( +
+ +
+
+ {device.local_name || device.device_model || device.platform} +
+
{device.platform}
+
+ +
+ ))} +
+
+ + {error && ( +

{error}

+ )} + + {canAddSlots ? ( + + ) : ( +

+ {t( + 'subscription.connectFooter.noTopupHint', + 'На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф.', + )} +

+ )} +
+
+ ); +} diff --git a/src/components/subscription/SubscriptionConnectFooter.tsx b/src/components/subscription/SubscriptionConnectFooter.tsx new file mode 100644 index 0000000..4200325 --- /dev/null +++ b/src/components/subscription/SubscriptionConnectFooter.tsx @@ -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; +} + +/** + * Подвал карточки подписки: подключить устройство либо разобраться с лимитом. + * + * Отдельная зона нажатия внутри карточки, поэтому карточка снаружи — не + * ` + ); +} diff --git a/src/components/subscription/SubscriptionListCard.tsx b/src/components/subscription/SubscriptionListCard.tsx index 3d4b101..bdcd8f3 100644 --- a/src/components/subscription/SubscriptionListCard.tsx +++ b/src/components/subscription/SubscriptionListCard.tsx @@ -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 }; + + // Подвал — своя зона нажатия, поэтому карточка снаружи не ` + + {isUnlimited + ? '∞' + : `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`} + + + {!isUnlimited && ( +
+
+
+ )} +
+ )} + + {/* Stats row */} +
+ {footer.kind === 'hidden' && ( + + + {subscription.device_limit} + + )} + + + {formatDate(subscription.end_date, i18n.language)} + + {!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 ( + + {enabled ? ( + + ) : ( + + + + )} + {label} + + ); + })()} +
+ + + connect?.onConnect()} + onManage={() => connect?.onManage()} + /> + ); } diff --git a/src/components/subscription/connectFooterState.test.ts b/src/components/subscription/connectFooterState.test.ts new file mode 100644 index 0000000..01b42d8 --- /dev/null +++ b/src/components/subscription/connectFooterState.test.ts @@ -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', + }); + }); +}); diff --git a/src/components/subscription/connectFooterState.ts b/src/components/subscription/connectFooterState.ts new file mode 100644 index 0000000..8dbeb04 --- /dev/null +++ b/src/components/subscription/connectFooterState.ts @@ -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, + }; +} diff --git a/src/components/ui/ResponsiveSheet.tsx b/src/components/ui/ResponsiveSheet.tsx new file mode 100644 index 0000000..d8d4850 --- /dev/null +++ b/src/components/ui/ResponsiveSheet.tsx @@ -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(isOpen && isDesktop, { onEscape: onClose }); + + if (!isOpen) return null; + + if (!isDesktop) { + return ( + + {children} + + ); + } + + return createPortal( +
+
+
+
+

{title}

+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/src/locales/en.json b/src/locales/en.json index b6d582b..1e6aa59 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2b3bcd4..69a2d3f 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -644,6 +644,16 @@ }, "dailyAutoCharge": "برداشت خودکار روزانه", "defaultName": "اشتراک", + "connectFooter": { + "connect": "اتصال دستگاه", + "full": "همه ظرفیت‌ها پر است", + "connectedDevices": "دستگاه‌های متصل", + "disconnect": "قطع اتصال", + "addSlots": "افزودن ظرفیت", + "limitExplained": "طرح «{{name}}» ‏{{count}} ظرفیت دارد و همه پر است. یکی را آزاد کنید یا ظرفیت اضافه کنید.", + "limitExplainedNoTopup": "طرح «{{name}}» ‏{{count}} ظرفیت دارد و همه پر است. یک ظرفیت را آزاد کنید.", + "noTopupHint": "روی اشتراک آزمایشی نمی‌توان ظرفیت خرید — یکی را آزاد کنید یا طرح پولی بگیرید." + }, "delete": "حذف", "deleteTitle": "حذف اشتراک", "get_config": "دریافت پیکربندی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 1b7e71f..a82666b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -758,6 +758,16 @@ "confirmDelete": "Удалить подписку?", "dailyAutoCharge": "Ежедневное списание", "defaultName": "Подписка", + "connectFooter": { + "connect": "Подключить устройство", + "full": "Все слоты заняты", + "connectedDevices": "Подключённые устройства", + "disconnect": "Отключить", + "addSlots": "Добавить слоты", + "limitExplained": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.", + "limitExplainedNoTopup": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.", + "noTopupHint": "На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф." + }, "delete": "Удалить", "deleteTitle": "Удаление подписки", "get_config": "Получить конфиг", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5f32b21..e2e6e24 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -644,6 +644,16 @@ }, "dailyAutoCharge": "每日自动扣费", "defaultName": "订阅", + "connectFooter": { + "connect": "连接设备", + "full": "所有名额已占满", + "connectedDevices": "已连接设备", + "disconnect": "断开", + "addSlots": "增加名额", + "limitExplained": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请释放一个名额或增加名额。", + "limitExplainedNoTopup": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请先释放一个名额。", + "noTopupHint": "试用订阅无法购买名额 — 请释放已占用的名额或改用付费套餐。" + }, "delete": "删除", "deleteTitle": "删除订阅", "get_config": "获取配置", diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 6b15a8d..153b368 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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(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', 'Управление')} →
- {multiSubData.subscriptions.slice(0, 3).map((sub) => ( + {visibleSubscriptions.map((sub, index) => ( navigate(`/subscriptions/${sub.id}`)} + connect={{ + connectedDevices: deviceQueries[index]?.data?.total, + onConnect: () => navigate(`/connection?sub=${sub.id}`), + onManage: () => setDeviceLimitSubId(sub.id), + }} /> ))} - {/* Подписку мог выдать бонус рекламной кампании — она создаётся сама, - и человек попадает на главную с готовым доступом. Пока подписка - одна, показываем здесь же, как подключить устройство: иначе за - этим нужно уходить на отдельную страницу, о чём он не догадается. */} - {homeSingleSub && ( - 0 - ? Math.min( - 100, - Math.round( - (homeSingleSub.traffic_used_gb / homeSingleSub.traffic_limit_gb) * 100, - ), - ) - : 0 - } - /> - )} {multiSubData.subscriptions.length > 3 && ( )} + + {deviceLimitSub && ( + 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}`); + }} + /> + )} ); }