The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
320 lines
10 KiB
TypeScript
320 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 { genUserName } from '@/lib/genUserName';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Progress } from '@/components/ui/progress';
|
|
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 {
|
|
Heart,
|
|
CheckCircle2,
|
|
Sparkles,
|
|
UserPlus,
|
|
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 { 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: 'Login Required',
|
|
description: 'Please log in to follow the founder',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isFollowing) {
|
|
toast({
|
|
title: 'Already Following',
|
|
description: `You're already following ${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]);
|
|
|
|
const handleHoldStart = useCallback((e: React.TouchEvent | React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (isFollowing) {
|
|
toast({
|
|
title: 'Already Following',
|
|
description: `You're already following ${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]);
|
|
|
|
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;
|