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>
198 lines
7.1 KiB
TypeScript
198 lines
7.1 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|
import { MapPin, Search, X, Plus } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SpotPickerProps {
|
|
selectedSpots: string[]; // Array of "pubkey:identifier" strings
|
|
onSpotsChange: (spots: string[]) => void;
|
|
maxSpots?: number;
|
|
label?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export function SpotPicker({
|
|
selectedSpots,
|
|
onSpotsChange,
|
|
maxSpots = 5,
|
|
label = 'Spots Where You\'re Available',
|
|
description = 'Select spots where you offer your services',
|
|
}: SpotPickerProps) {
|
|
const { data: allSpots } = useSkateSpots(100);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
// Filter spots for search
|
|
const filteredSpots = useMemo(() => {
|
|
if (!allSpots) return [];
|
|
if (!searchQuery) return allSpots.slice(0, 10);
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
return allSpots
|
|
.filter(s =>
|
|
s.title.toLowerCase().includes(query) ||
|
|
s.location?.toLowerCase().includes(query)
|
|
)
|
|
.slice(0, 10);
|
|
}, [allSpots, searchQuery]);
|
|
|
|
// Get selected spot details
|
|
const selectedSpotDetails = useMemo(() => {
|
|
if (!allSpots) return [];
|
|
return selectedSpots
|
|
.map(spotId => {
|
|
const [pubkey, identifier] = spotId.split(':');
|
|
return allSpots.find(s => s.pubkey === pubkey && s.identifier === identifier);
|
|
})
|
|
.filter(Boolean);
|
|
}, [allSpots, selectedSpots]);
|
|
|
|
const toggleSpot = (pubkey: string, identifier: string) => {
|
|
const spotId = `${pubkey}:${identifier}`;
|
|
if (selectedSpots.includes(spotId)) {
|
|
onSpotsChange(selectedSpots.filter(id => id !== spotId));
|
|
} else if (selectedSpots.length < maxSpots) {
|
|
onSpotsChange([...selectedSpots, spotId]);
|
|
}
|
|
};
|
|
|
|
const removeSpot = (spotId: string) => {
|
|
onSpotsChange(selectedSpots.filter(id => id !== spotId));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-base font-bold">{label}</Label>
|
|
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
|
</div>
|
|
<Badge variant="outline" className="text-xs">
|
|
{selectedSpots.length}/{maxSpots}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Selected spots */}
|
|
{selectedSpotDetails.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedSpotDetails.map((spot) => spot && (
|
|
<Badge
|
|
key={`${spot.pubkey}:${spot.identifier}`}
|
|
variant="secondary"
|
|
className="gap-1.5 py-1.5 px-3 bg-[#ff0040]/10 border border-[#ff0040]/30 text-[#ff0040]"
|
|
>
|
|
<MapPin className="h-3 w-3" />
|
|
{spot.title}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeSpot(`${spot.pubkey}:${spot.identifier}`)}
|
|
className="ml-1 hover:bg-[#ff0040]/20 rounded-full p-0.5"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add spot button / expanded picker */}
|
|
{selectedSpots.length < maxSpots && (
|
|
<>
|
|
{!isExpanded ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2 border-dashed"
|
|
onClick={() => setIsExpanded(true)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Spot
|
|
</Button>
|
|
) : (
|
|
<Card className="bg-gray-900/50 border-gray-700">
|
|
<CardContent className="p-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
|
<Input
|
|
placeholder="Search spots..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="bg-gray-800 border-gray-700 pl-9 h-9"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsExpanded(false);
|
|
setSearchQuery('');
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="max-h-48 overflow-y-auto space-y-1.5">
|
|
{filteredSpots.map((spot) => {
|
|
const spotId = `${spot.pubkey}:${spot.identifier}`;
|
|
const isSelected = selectedSpots.includes(spotId);
|
|
|
|
return (
|
|
<button
|
|
key={spotId}
|
|
type="button"
|
|
className={cn(
|
|
'w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors text-sm',
|
|
isSelected
|
|
? 'bg-[#ff0040]/20 border border-[#ff0040]/50'
|
|
: 'hover:bg-gray-800 border border-transparent'
|
|
)}
|
|
onClick={() => toggleSpot(spot.pubkey, spot.identifier)}
|
|
disabled={!isSelected && selectedSpots.length >= maxSpots}
|
|
>
|
|
<MapPin className={cn(
|
|
'h-4 w-4 flex-shrink-0',
|
|
isSelected ? 'text-[#ff0040]' : 'text-gray-500'
|
|
)} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{spot.title}</div>
|
|
{spot.location && (
|
|
<div className="text-xs text-gray-400 truncate">{spot.location}</div>
|
|
)}
|
|
</div>
|
|
{isSelected && (
|
|
<Badge variant="outline" className="text-[10px] border-[#ff0040]/50 text-[#ff0040]">
|
|
Selected
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{filteredSpots.length === 0 && (
|
|
<p className="text-sm text-gray-400 text-center py-4">
|
|
No spots found. Try a different search.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{selectedSpots.length === 0 && (
|
|
<p className="text-xs text-gray-400 italic">
|
|
Adding spots helps users find you when browsing specific locations
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|