feat: add HoverBorderGradient effect to key action buttons

Apply animated gradient border effect (Aceternity UI) to:
- Dashboard "Connect Devices" button
- Dashboard "Activate Trial" buttons (free and paid variants)
- Subscription page "Connect Devices" button
This commit is contained in:
Fringg
2026-02-25 08:37:28 +03:00
parent 5c1be1471e
commit 3fb9606fd0
3 changed files with 111 additions and 14 deletions

View File

@@ -0,0 +1,93 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
type Direction = 'TOP' | 'LEFT' | 'BOTTOM' | 'RIGHT';
export function HoverBorderGradient({
children,
containerClassName,
className,
as: Tag = 'button',
duration = 1,
clockwise = true,
...props
}: React.PropsWithChildren<
{
as?: React.ElementType;
containerClassName?: string;
className?: string;
duration?: number;
clockwise?: boolean;
} & React.HTMLAttributes<HTMLElement>
>) {
const [hovered, setHovered] = useState<boolean>(false);
const [direction, setDirection] = useState<Direction>('TOP');
const rotateDirection = (currentDirection: Direction): Direction => {
const directions: Direction[] = ['TOP', 'LEFT', 'BOTTOM', 'RIGHT'];
const currentIndex = directions.indexOf(currentDirection);
const nextIndex = clockwise
? (currentIndex - 1 + directions.length) % directions.length
: (currentIndex + 1) % directions.length;
return directions[nextIndex];
};
const movingMap: Record<Direction, string> = {
TOP: 'radial-gradient(20.7% 50% at 50% 0%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)',
LEFT: 'radial-gradient(16.6% 43.1% at 0% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)',
BOTTOM:
'radial-gradient(20.7% 50% at 50% 100%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)',
RIGHT:
'radial-gradient(16.2% 41.2% at 100% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)',
};
const highlight =
'radial-gradient(75% 181.16% at 50% 50%, #3275F8 0%, rgba(255, 255, 255, 0) 100%)';
useEffect(() => {
if (!hovered) {
const interval = setInterval(() => {
setDirection((prevState) => rotateDirection(prevState));
}, duration * 1000);
return () => clearInterval(interval);
}
}, [hovered, duration]);
return (
<Tag
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn(
'relative flex content-center items-center justify-center overflow-visible rounded-xl border-none bg-black/20 p-px transition duration-500 hover:bg-black/10',
containerClassName,
)}
{...props}
>
<div
className={cn(
'z-10 w-auto rounded-[inherit] bg-dark-800 px-4 py-2.5 text-sm font-medium text-white',
className,
)}
>
{children}
</div>
<motion.div
className="absolute inset-0 z-0 flex-none overflow-hidden rounded-[inherit]"
style={{
filter: 'blur(2px)',
position: 'absolute',
width: '100%',
height: '100%',
}}
initial={{ background: movingMap[direction] }}
animate={{
background: hovered ? [movingMap[direction], highlight] : movingMap[direction],
}}
transition={{ ease: 'linear', duration: duration ?? 1 }}
/>
<div className="absolute inset-[2px] z-[1] flex-none rounded-[10px] bg-dark-800" />
</Tag>
);
}

View File

@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance';
import { wheelApi } from '../api/wheel'; import { wheelApi } from '../api/wheel';
import Onboarding, { useOnboarding } from '../components/Onboarding'; import Onboarding, { useOnboarding } from '../components/Onboarding';
import PromoOffersSection from '../components/PromoOffersSection'; import PromoOffersSection from '../components/PromoOffersSection';
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants'; import { API } from '../config/constants';
@@ -411,13 +412,14 @@ export default function Dashboard() {
{t('dashboard.viewSubscription')} {t('dashboard.viewSubscription')}
</Link> </Link>
{subscription.subscription_url && ( {subscription.subscription_url && (
<button <HoverBorderGradient
onClick={() => navigate('/connection')} onClick={() => navigate('/connection')}
className="btn-secondary py-2.5 text-sm" containerClassName="w-full"
className="flex w-full items-center justify-center py-2.5 text-sm"
data-onboarding="connect-devices" data-onboarding="connect-devices"
> >
{t('subscription.getConfig')} {t('subscription.getConfig')}
</button> </HoverBorderGradient>
)} )}
</div> </div>
</div> </div>
@@ -590,30 +592,30 @@ export default function Dashboard() {
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? ( {trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? ( (balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
<button <HoverBorderGradient
onClick={() => activateTrialMutation.mutate()} onClick={() => activateTrialMutation.mutate()}
disabled={activateTrialMutation.isPending} containerClassName={`w-full ${activateTrialMutation.isPending ? 'pointer-events-none opacity-50' : ''}`}
className="btn-primary w-full" className="flex w-full items-center justify-center"
> >
{activateTrialMutation.isPending {activateTrialMutation.isPending
? t('common.loading', 'Loading...') ? t('common.loading', 'Loading...')
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')} : t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
</button> </HoverBorderGradient>
) : ( ) : (
<Link to="/balance" className="btn-primary block w-full text-center"> <Link to="/balance" className="btn-primary block w-full text-center">
{t('subscription.trial.topUpToActivate', 'Top Up Balance')} {t('subscription.trial.topUpToActivate', 'Top Up Balance')}
</Link> </Link>
) )
) : ( ) : (
<button <HoverBorderGradient
onClick={() => activateTrialMutation.mutate()} onClick={() => activateTrialMutation.mutate()}
disabled={activateTrialMutation.isPending} containerClassName={`w-full ${activateTrialMutation.isPending ? 'pointer-events-none opacity-50' : ''}`}
className="btn-primary w-full" className="flex w-full items-center justify-center"
> >
{activateTrialMutation.isPending {activateTrialMutation.isPending
? t('common.loading', 'Loading...') ? t('common.loading', 'Loading...')
: t('subscription.trial.activate', 'Activate Free Trial')} : t('subscription.trial.activate', 'Activate Free Trial')}
</button> </HoverBorderGradient>
)} )}
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { subscriptionApi } from '../api/subscription'; import { subscriptionApi } from '../api/subscription';
import { promoApi } from '../api/promo'; import { promoApi } from '../api/promo';
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
import type { import type {
PurchaseSelection, PurchaseSelection,
PeriodOption, PeriodOption,
@@ -756,9 +757,10 @@ export default function Subscription() {
</div> </div>
{/* Get Config Button */} {/* Get Config Button */}
<button <HoverBorderGradient
onClick={() => navigate('/connection')} onClick={() => navigate('/connection')}
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3" containerClassName="mb-3 w-full"
className="flex w-full items-center justify-center gap-2 py-3"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -774,7 +776,7 @@ export default function Subscription() {
/> />
</svg> </svg>
{t('subscription.getConfig')} {t('subscription.getConfig')}
</button> </HoverBorderGradient>
{/* Subscription URL - hidden when hide_subscription_link is true */} {/* Subscription URL - hidden when hide_subscription_link is true */}
{!subscription.hide_subscription_link && ( {!subscription.hide_subscription_link && (