mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
fix: eliminate hover flickering across all pages
- Extract CountdownTimer into React.memo component to isolate 1s interval re-renders from the full Subscription page - Add box-shadow transition to .hover-border-gradient (was instant) - Remove conflicting p-[1.5px] from PurchaseCTAButton (CSS border already handles 1.5px) - Replace transition-all with specific properties on bento-card-hover, bento-card-glow, device dots, toggle switches, progress bars - Reduce translateY hover from -4px to -2px to prevent cursor losing hover target during fast vertical mouse movement
This commit is contained in:
@@ -32,10 +32,10 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
||||
<HoverBorderGradient
|
||||
accentColor={accentColor}
|
||||
duration={4}
|
||||
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl p-[1.5px]"
|
||||
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-between rounded-[14px] px-5 py-4 transition-all duration-300"
|
||||
className="relative flex items-center justify-between rounded-[14px] px-5 py-4 transition-colors duration-300"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,107,53,0.06))'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -20,6 +20,146 @@ import {
|
||||
getFlagEmoji,
|
||||
} from '../utils/subscriptionHelpers';
|
||||
|
||||
/** Isolated countdown so 1s interval doesn't re-render the whole page */
|
||||
const CountdownTimer = memo(function CountdownTimer({
|
||||
endDate,
|
||||
isActive,
|
||||
glassColors: g,
|
||||
}: {
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
glassColors: ReturnType<typeof getGlassColors>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const endTime = new Date(endDate).getTime();
|
||||
const tick = () => {
|
||||
const diff = Math.max(0, endTime - Date.now());
|
||||
setCountdown({
|
||||
days: Math.floor(diff / 86_400_000),
|
||||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||||
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
||||
seconds: Math.floor((diff % 60_000) / 1_000),
|
||||
});
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [endDate]);
|
||||
|
||||
const isExpired = !isActive;
|
||||
const isUrgent = countdown.days <= 3;
|
||||
|
||||
const formattedDate = new Date(endDate).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] p-3.5"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'rgba(255,59,92,0.06)'
|
||||
: isUrgent
|
||||
? 'rgba(255,184,0,0.06)'
|
||||
: g.innerBg,
|
||||
border: isExpired
|
||||
? '1px solid rgba(255,59,92,0.15)'
|
||||
: isUrgent
|
||||
? '1px solid rgba(255,184,0,0.15)'
|
||||
: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded-[7px]"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'rgba(255,59,92,0.1)'
|
||||
: isUrgent
|
||||
? 'rgba(255,184,0,0.1)'
|
||||
: g.hoverBg,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={isExpired ? '#FF3B5C' : isUrgent ? '#FFB800' : g.textSecondary}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
</div>
|
||||
{t('dashboard.remaining')}
|
||||
</div>
|
||||
{isExpired ? (
|
||||
<div className="text-[18px] font-bold tracking-tight" style={{ color: '#FF3B5C' }}>
|
||||
{t('subscription.expired')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1 font-mono tabular-nums">
|
||||
{countdown.days > 0 && (
|
||||
<>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{countdown.days}
|
||||
</span>
|
||||
<span className="mr-1 text-[10px] font-medium text-dark-50/25">
|
||||
{t('subscription.daysShort')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.hours).padStart(2, '0')}
|
||||
</span>
|
||||
<span
|
||||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
:
|
||||
</span>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.minutes).padStart(2, '0')}
|
||||
</span>
|
||||
<span
|
||||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
:
|
||||
</span>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.seconds).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] font-medium text-dark-50/25">
|
||||
{t('subscription.expiresAt')}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function Subscription() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -61,25 +201,6 @@ export default function Subscription() {
|
||||
// Extract subscription from response (null if no subscription)
|
||||
const subscription = subscriptionResponse?.subscription ?? null;
|
||||
|
||||
// Live countdown timer
|
||||
const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
|
||||
useEffect(() => {
|
||||
if (!subscription?.end_date) return;
|
||||
const endTime = new Date(subscription.end_date).getTime();
|
||||
const tick = () => {
|
||||
const diff = Math.max(0, endTime - Date.now());
|
||||
setCountdown({
|
||||
days: Math.floor(diff / 86_400_000),
|
||||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||||
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
||||
seconds: Math.floor((diff % 60_000) / 1_000),
|
||||
});
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [subscription?.end_date]);
|
||||
|
||||
// Purchase options (needed for balance_kopeks in device/traffic/server management)
|
||||
const { data: purchaseOptions } = useQuery({
|
||||
queryKey: ['purchase-options'],
|
||||
@@ -316,7 +437,6 @@ export default function Subscription() {
|
||||
(trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0;
|
||||
const zone = getTrafficZone(usedPercent);
|
||||
const connectedDevices = devicesData?.total ?? 0;
|
||||
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -576,7 +696,7 @@ export default function Subscription() {
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||
className="h-[7px] w-[7px] rounded-full transition-[background-color,box-shadow] duration-300"
|
||||
style={{
|
||||
background: i < connectedDevices ? zone.mainHex : g.textGhost,
|
||||
boxShadow: i < connectedDevices ? `0 0 6px ${zone.mainHex}50` : 'none',
|
||||
@@ -601,7 +721,7 @@ export default function Subscription() {
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className="flex h-auto items-center rounded-[10px] px-3 transition-all duration-300"
|
||||
className="flex h-auto items-center rounded-[10px] px-3 transition-colors duration-300"
|
||||
style={{
|
||||
background: copied ? 'rgba(62,219,176,0.12)' : g.innerBorder,
|
||||
border: copied ? '1px solid rgba(62,219,176,0.2)' : `1px solid ${g.trackBg}`,
|
||||
@@ -616,114 +736,12 @@ export default function Subscription() {
|
||||
|
||||
{/* ─── Stats Row ─── */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-2.5">
|
||||
{/* Countdown timer */}
|
||||
{(() => {
|
||||
const isUrgent = countdown.days <= 3;
|
||||
const isExpired = !subscription.is_active;
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] p-3.5"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'rgba(255,59,92,0.06)'
|
||||
: isUrgent
|
||||
? 'rgba(255,184,0,0.06)'
|
||||
: g.innerBg,
|
||||
border: isExpired
|
||||
? '1px solid rgba(255,59,92,0.15)'
|
||||
: isUrgent
|
||||
? '1px solid rgba(255,184,0,0.15)'
|
||||
: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded-[7px]"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'rgba(255,59,92,0.1)'
|
||||
: isUrgent
|
||||
? 'rgba(255,184,0,0.1)'
|
||||
: g.hoverBg,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={isExpired ? '#FF3B5C' : isUrgent ? '#FFB800' : g.textSecondary}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
</div>
|
||||
{t('dashboard.remaining')}
|
||||
</div>
|
||||
{isExpired ? (
|
||||
<div
|
||||
className="text-[18px] font-bold tracking-tight"
|
||||
style={{ color: '#FF3B5C' }}
|
||||
>
|
||||
{t('subscription.expired')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1 font-mono tabular-nums">
|
||||
{countdown.days > 0 && (
|
||||
<>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{countdown.days}
|
||||
</span>
|
||||
<span className="mr-1 text-[10px] font-medium text-dark-50/25">
|
||||
{t('subscription.daysShort')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.hours).padStart(2, '0')}
|
||||
</span>
|
||||
<span
|
||||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
:
|
||||
</span>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.minutes).padStart(2, '0')}
|
||||
</span>
|
||||
<span
|
||||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
:
|
||||
</span>
|
||||
<span
|
||||
className="text-[20px] font-bold tracking-tight"
|
||||
style={{ color: isUrgent ? '#FFB800' : g.text }}
|
||||
>
|
||||
{String(countdown.seconds).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] font-medium text-dark-50/25">
|
||||
{t('subscription.expiresAt')}: {formattedDate}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Countdown timer — isolated to prevent 1s re-renders of entire page */}
|
||||
<CountdownTimer
|
||||
endDate={subscription.end_date}
|
||||
isActive={subscription.is_active}
|
||||
glassColors={g}
|
||||
/>
|
||||
|
||||
{/* Devices */}
|
||||
<div
|
||||
@@ -858,7 +876,7 @@ export default function Subscription() {
|
||||
style={{ background: g.trackBg }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full transition-all duration-500"
|
||||
className="absolute inset-0 rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${purchase.progress_percent}%`,
|
||||
background: `linear-gradient(90deg, ${zone.mainHex}, ${zone.mainHex}80)`,
|
||||
@@ -897,13 +915,13 @@ export default function Subscription() {
|
||||
<button
|
||||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||||
disabled={autopayMutation.isPending}
|
||||
className="relative h-7 w-[52px] rounded-full transition-all duration-300"
|
||||
className="relative h-7 w-[52px] rounded-full transition-colors duration-300"
|
||||
style={{
|
||||
background: subscription.autopay_enabled ? zone.mainHex : g.textGhost,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-all duration-300"
|
||||
className="absolute top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-[left] duration-300"
|
||||
style={{
|
||||
left: subscription.autopay_enabled ? '26px' : '3px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
@@ -973,7 +991,7 @@ export default function Subscription() {
|
||||
<button
|
||||
onClick={() => pauseMutation.mutate()}
|
||||
disabled={pauseMutation.isPending}
|
||||
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-all duration-300"
|
||||
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background: subscription.is_daily_paused
|
||||
? 'rgba(62,219,176,0.12)'
|
||||
@@ -1084,7 +1102,7 @@ export default function Subscription() {
|
||||
style={{ background: g.trackBg }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full transition-all duration-500"
|
||||
className="absolute inset-0 rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: 'linear-gradient(90deg, #00E5A0, #00C987)',
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
animation: border-rotate var(--border-duration, 3s) linear infinite;
|
||||
}
|
||||
|
||||
.hover-border-gradient {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-border-gradient:hover,
|
||||
.hover-border-gradient:active {
|
||||
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
|
||||
@@ -370,12 +374,16 @@ img.twemoji {
|
||||
|
||||
.bento-card-hover {
|
||||
@apply bento-card cursor-pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bento-card-hover:hover {
|
||||
@apply border-dark-600/50 bg-dark-800/60 shadow-lg;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px);
|
||||
/* Intensify Glass Border & Add Top Spotlight */
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
||||
@@ -405,12 +413,15 @@ img.twemoji {
|
||||
|
||||
.bento-card-glow {
|
||||
@apply bento-card cursor-pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bento-card-glow:hover {
|
||||
@apply border-accent-500/30 shadow-glow;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.bento-card-glow:active {
|
||||
@@ -451,7 +462,7 @@ img.twemoji {
|
||||
|
||||
.light .bento-card-hover:hover {
|
||||
@apply border-champagne-400/50 bg-champagne-50 shadow-md;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px);
|
||||
/* Intensify Light Theme Glass Border */
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
|
||||
@@ -465,14 +476,14 @@ img.twemoji {
|
||||
|
||||
.light .bento-card-glow:hover {
|
||||
@apply border-accent-400/40 shadow-lg;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ========== DARK THEME COMPONENTS (default) ========== */
|
||||
|
||||
/* Cards - Dark (unified style) */
|
||||
.card {
|
||||
@apply border border-dark-700/40 bg-dark-900/70 p-5 transition-all duration-200 sm:p-6;
|
||||
@apply border border-dark-700/40 bg-dark-900/70 p-5 transition-colors duration-200 sm:p-6;
|
||||
border-radius: var(--bento-radius);
|
||||
transform: translateZ(0);
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
|
||||
Reference in New Issue
Block a user