SkateSpots/src/components/FounderFollow.tsx

309 lines
10 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { useFollowActions, useIsFollowing } from '@/hooks/useFollowing';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckCircle2, Sparkles, Crown, Zap } from 'lucide-react';
// The founder's npub - decode to hex pubkey
const FOUNDER_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
const HOLD_DURATION = 3000; // 3 seconds
interface FounderFollowProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
showBadge?: boolean;
}
export function FounderFollow({ className, size = 'md', showBadge = true }: FounderFollowProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { follow } = useFollowActions();
const isFollowing = useIsFollowing(FOUNDER_PUBKEY);
const author = useAuthor(FOUNDER_PUBKEY);
const { toast } = useToast();
const [isHolding, setIsHolding] = useState(false);
const [progress, setProgress] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const holdTimer = useRef<NodeJS.Timeout | null>(null);
const progressInterval = useRef<NodeJS.Timeout | null>(null);
const startTime = useRef<number>(0);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(FOUNDER_PUBKEY);
const avatar = metadata?.picture;
const sizeClasses = {
sm: 'h-10 w-10',
md: 'h-14 w-14',
lg: 'h-20 w-20',
};
const cleanup = useCallback(() => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
if (progressInterval.current) {
clearInterval(progressInterval.current);
progressInterval.current = null;
}
setIsHolding(false);
setProgress(0);
}, []);
const handleHoldComplete = useCallback(async () => {
cleanup();
if (!user) {
toast({
title: t('toastsUi.loginRequired'),
description: t('toastsUi.loginToFollowFounder'),
variant: 'destructive',
});
return;
}
if (isFollowing) {
toast({
title: t('toastsUi.alreadyFollowing'),
description: t('toastsUi.alreadyFollowingDesc', { name: displayName }),
});
return;
}
try {
await follow(FOUNDER_PUBKEY);
setShowConfetti(true);
setShowDialog(true);
// Hide confetti after animation
setTimeout(() => setShowConfetti(false), 3000);
} catch (error) {
console.error('[FounderFollow] Failed to follow:', error);
}
}, [user, isFollowing, follow, displayName, cleanup, toast, t]);
const handleHoldStart = useCallback((e: React.TouchEvent | React.MouseEvent) => {
e.preventDefault();
if (isFollowing) {
toast({
title: t('toastsUi.alreadyFollowing'),
description: t('toastsUi.alreadyFollowingDesc', { name: displayName }),
});
return;
}
setIsHolding(true);
startTime.current = Date.now();
// Progress animation
progressInterval.current = setInterval(() => {
const elapsed = Date.now() - startTime.current;
const newProgress = Math.min((elapsed / HOLD_DURATION) * 100, 100);
setProgress(newProgress);
if (newProgress >= 100) {
handleHoldComplete();
}
}, 16); // ~60fps
// Timeout as backup
holdTimer.current = setTimeout(handleHoldComplete, HOLD_DURATION);
}, [isFollowing, displayName, handleHoldComplete, toast, t]);
const handleHoldEnd = useCallback(() => {
cleanup();
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
return (
<>
<div className={cn('relative inline-block', className)}>
{/* Confetti effect */}
{showConfetti && (
<div className="absolute inset-0 pointer-events-none overflow-visible z-50">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="absolute animate-confetti"
style={{
left: '50%',
top: '50%',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: ['#ff0040', '#ccff00', '#ff6600', '#00ccff', '#9933ff'][i % 5],
animationDelay: `${i * 0.05}s`,
transform: `rotate(${i * 18}deg) translateY(-40px)`,
}}
/>
))}
</div>
)}
{/* Avatar with hold effect */}
<div
onMouseDown={handleHoldStart}
onMouseUp={handleHoldEnd}
onMouseLeave={handleHoldEnd}
onTouchStart={handleHoldStart}
onTouchEnd={handleHoldEnd}
onTouchCancel={handleHoldEnd}
className={cn(
'relative cursor-pointer transition-all duration-200 select-none',
isHolding && 'scale-110'
)}
>
{/* Progress ring */}
{isHolding && (
<svg
className="absolute -inset-1 z-10 -rotate-90"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="#ff6600"
strokeWidth="4"
strokeDasharray={`${progress * 2.89} 289`}
className="drop-shadow-[0_0_8px_#ff6600]"
/>
</svg>
)}
{/* Glow effect */}
<div
className={cn(
'absolute -inset-2 rounded-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] opacity-0 blur-lg transition-opacity duration-300',
isHolding && 'opacity-60',
isFollowing && 'opacity-30'
)}
/>
<Avatar
className={cn(
sizeClasses[size],
'border-2 transition-all duration-200',
isFollowing ? 'border-[#ccff00]' : 'border-[#ff6600]/50',
isHolding && 'border-[#ff6600] shadow-[0_0_20px_rgba(255,102,0,0.5)]'
)}
>
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gradient-to-br from-[#ff6600] to-[#ff0040] text-white font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Following indicator */}
{isFollowing && showBadge && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-[#ccff00] border-2 border-black flex items-center justify-center">
<CheckCircle2 className="h-3 w-3 text-black" />
</div>
)}
{/* Crown for founder */}
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<Crown className="h-4 w-4 text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.8)]" />
</div>
</div>
{/* Hold hint text */}
{!isFollowing && !isHolding && (
<p className="text-[10px] text-center text-muted-foreground mt-1.5 max-w-[80px]">
Hold to follow founder
</p>
)}
{/* Holding progress text */}
{isHolding && (
<p className="text-[10px] text-center text-[#ff6600] mt-1.5 font-bold animate-pulse">
{Math.floor(progress)}% Hold...
</p>
)}
</div>
{/* Success Dialog */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="bg-black/95 border-2 border-[#ccff00]/50 max-w-sm">
<DialogHeader>
<DialogTitle className="text-center text-2xl font-black text-[#ccff00] flex items-center justify-center gap-2">
<Sparkles className="h-6 w-6" />
Welcome!
<Sparkles className="h-6 w-6" />
</DialogTitle>
<DialogDescription className="text-center text-gray-300 pt-2">
You're now following the SkateSpots founder!
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center py-6 space-y-4">
<Avatar className="h-24 w-24 border-4 border-[#ccff00] shadow-[0_0_30px_rgba(204,255,0,0.3)]">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gradient-to-br from-[#ff6600] to-[#ff0040] text-white text-2xl font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="font-black text-xl text-white">{displayName}</h3>
<Badge className="mt-2 bg-gradient-to-r from-[#ff6600] to-[#ff0040] text-white">
<Crown className="h-3 w-3 mr-1" />
Founder
</Badge>
</div>
<div className="text-center text-sm text-muted-foreground max-w-xs">
<p>Stay connected to get updates on new features, community events, and skate spot discoveries!</p>
</div>
<Button
onClick={() => setShowDialog(false)}
className="w-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Let's Go!
</Button>
</div>
</DialogContent>
</Dialog>
{/* CSS for confetti animation */}
<style>{`
@keyframes confetti {
0% {
transform: rotate(var(--rotation)) translateY(0) scale(1);
opacity: 1;
}
100% {
transform: rotate(var(--rotation)) translateY(-100px) scale(0);
opacity: 0;
}
}
.animate-confetti {
animation: confetti 1s ease-out forwards;
}
`}</style>
</>
);
}
export default FounderFollow;