mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
На главной подключение жило отдельной плиткой под карточкой, и показывалось только когда подписка одна: при нескольких «выбор устройства неоднозначен». Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и покупка новой, — из которых вторая ровней не была. При нескольких подписках подключение с главной пропадало вовсе. Теперь это подвал самой карточки: тело ведёт в подписку, подвал — в подключение. Одна карточка на подписку при любом их числе, отдельных плиток нет. Карточка снаружи перестала быть `<button>` — вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо, поэтому это обёртка с двумя зонами нажатия. Подвал включается пропом `connect` и задан только на главной: список «Все подписки» остаётся перечнем без действий. Где подвал есть, из строки статистики убран счётчик лимита устройств — он дублировал бы счётчик подвала. Акцентом подсвечивается только подписка без единого устройства: там действие и нужно. У остальных подвал приглушён и не спорит с карточкой. Исчерпанный лимит не блокирует кнопку, а ведёт в разбор: сколько слотов даёт тариф, какие устройства их занимают, отключить лишнее либо добавить слоты. Докупка скрыта на тестовой подписке и при безлимите — те же условия, что у блока докупки на странице подписки, — и тогда текст не обещает того, чего сделать нельзя. Пока счётчик устройств не пришёл, подвал показывает скелетон, а не догадку о состоянии. Его высоту задаёт невидимый спан с той же типографикой: подбор числом даёт дробные пиксели строчного бокса, и карточка дёргается. Проверено замером — сдвиг 0px на всех состояниях. ResponsiveSheet: на телефоне нижний шит, с 640px обычное центрированное окно. Шит на десктопе — плашка у нижнего края с ручкой перетаскивания, бессмысленной для мыши. Компонент общий, остальные шиты кабинета смогут его переиспользовать. ConnectDeviceTile не тронут и продолжает жить в карточке подписки одиночного тарифа вместе с якорем онбординга. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
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,
|
||
);
|
||
}
|