mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
93
src/components/ui/hover-border-gradient.tsx
Normal file
93
src/components/ui/hover-border-gradient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user