Files
bedolaga-cabinet/src/components/ui/ResponsiveSheet.tsx
c0mrade 28e25a9292 feat(dashboard): подключение устройства прикреплено к карточке подписки
На главной подключение жило отдельной плиткой под карточкой, и показывалось
только когда подписка одна: при нескольких «выбор устройства неоднозначен».
Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и
покупка новой, — из которых вторая ровней не была. При нескольких подписках
подключение с главной пропадало вовсе.

Теперь это подвал самой карточки: тело ведёт в подписку, подвал — в
подключение. Одна карточка на подписку при любом их числе, отдельных плиток
нет. Карточка снаружи перестала быть `<button>` — вложенные кнопки невалидны
и ведут себя в браузерах непредсказуемо, поэтому это обёртка с двумя зонами
нажатия.

Подвал включается пропом `connect` и задан только на главной: список «Все
подписки» остаётся перечнем без действий. Где подвал есть, из строки
статистики убран счётчик лимита устройств — он дублировал бы счётчик подвала.

Акцентом подсвечивается только подписка без единого устройства: там действие
и нужно. У остальных подвал приглушён и не спорит с карточкой.

Исчерпанный лимит не блокирует кнопку, а ведёт в разбор: сколько слотов даёт
тариф, какие устройства их занимают, отключить лишнее либо добавить слоты.
Докупка скрыта на тестовой подписке и при безлимите — те же условия, что у
блока докупки на странице подписки, — и тогда текст не обещает того, чего
сделать нельзя.

Пока счётчик устройств не пришёл, подвал показывает скелетон, а не догадку о
состоянии. Его высоту задаёт невидимый спан с той же типографикой: подбор
числом даёт дробные пиксели строчного бокса, и карточка дёргается. Проверено
замером — сдвиг 0px на всех состояниях.

ResponsiveSheet: на телефоне нижний шит, с 640px обычное центрированное окно.
Шит на десктопе — плашка у нижнего края с ручкой перетаскивания, бессмысленной
для мыши. Компонент общий, остальные шиты кабинета смогут его переиспользовать.

ConnectDeviceTile не тронут и продолжает жить в карточке подписки одиночного
тарифа вместе с якорем онбординга.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:55:27 +03:00

91 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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,
);
}