mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
feat(dashboard): плитка «Подключить устройство» на главной
Подписку может выдать бонус рекламной кампании — она создаётся автоматически, и человек попадает на главную с уже готовым доступом. Плитка подключения жила только в карточке подписки на отдельной странице, так что на главной ему нечем было воспользоваться: подписка есть, а как подключить устройство — не видно. Плитка вынесена из SubscriptionCardActive в отдельный компонент и переиспользована обоими экранами, второй копии разметки не появилось. На главной показывается, пока подписка одна: при нескольких выбор устройства неоднозначен, и уместнее уходить в конкретную подписку.
This commit is contained in:
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">
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -301,6 +302,28 @@ export default function Dashboard() {
|
|||||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Подписку мог выдать бонус рекламной кампании — она создаётся сама,
|
||||||
|
и человек попадает на главную с готовым доступом. Пока подписка
|
||||||
|
одна, показываем здесь же, как подключить устройство: иначе за
|
||||||
|
этим нужно уходить на отдельную страницу, о чём он не догадается. */}
|
||||||
|
{multiSubData.subscriptions.length === 1 && (
|
||||||
|
<ConnectDeviceTile
|
||||||
|
subscription={multiSubData.subscriptions[0]}
|
||||||
|
connectedDevices={devicesData?.total ?? 0}
|
||||||
|
usedPercent={
|
||||||
|
multiSubData.subscriptions[0].traffic_limit_gb > 0
|
||||||
|
? Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
(multiSubData.subscriptions[0].traffic_used_gb /
|
||||||
|
multiSubData.subscriptions[0].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