import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { SpotMap } from '@/components/SpotMap'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { ZapButton } from '@/components/ZapButton'; import { ClinkPayButton } from '@/components/ClinkPayButton'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { OfficialBadge } from '@/components/OfficialBadge'; import { useShop } from '@/hooks/useShops'; import { useAuthor } from '@/hooks/useAuthor'; import { useDeleteEntity } from '@/hooks/useDeleteEntity'; import { isAdminPubkey } from '@/lib/admins'; import { cn } from '@/lib/utils'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import NotFound from './NotFound'; import { Store, ArrowLeft, Share2, Navigation, Calendar, Pencil, Trash2, AlertTriangle, Shield, Phone, Mail, Globe, Instagram, Clock, CheckCircle2, } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; function ShopDetailSkeleton() { return (
); } export default function ShopDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: shop, isLoading, error } = useShop(identifier || '', pubkey); const author = useAuthor(shop?.pubkey); const { user } = useCurrentUser(); const { mutateAsync: deleteEntity } = useDeleteEntity(); const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const isOwner = user && shop && user.pubkey === shop.pubkey; const isAdmin = isAdminPubkey(user?.pubkey); // Owner can delete their own shop; a SkateSpots admin can take down any shop. const canDelete = !!user && !!shop && (isOwner || isAdmin); const adminTakedown = isAdmin && !isOwner; const hasNip05 = user?.metadata?.nip05; useSeoMeta({ title: shop ? `${shop.name} - SkateSpots` : 'Skate Shop - SkateSpots', description: shop?.description || 'View this skate shop on SkateSpots.', }); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(shop?.pubkey || ''); const coordinates = useMemo(() => { if (!shop?.geohash) return null; const { latitude, longitude } = ngeohash.decode(shop.geohash); return { lat: latitude, lng: longitude }; }, [shop?.geohash]); // Check if shop owner's NIP-05 domain matches website domain const isVerifiedOwner = useMemo(() => { if (!metadata?.nip05 || !shop?.website) return false; const nip05Domain = metadata.nip05.split('@')[1]; const websiteDomain = shop.website.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]; return nip05Domain.toLowerCase() === websiteDomain.toLowerCase(); }, [metadata?.nip05, shop?.website]); const handleShare = async () => { if (navigator.share) { try { await navigator.share({ title: shop?.name, text: shop?.description || `Check out this skate shop: ${shop?.name}`, url: window.location.href, }); } catch { // User cancelled or share failed } } else { navigator.clipboard.writeText(window.location.href); toast({ title: 'Link copied!', description: 'Shop link copied to clipboard.', }); } }; const handleDirections = () => { if (coordinates) { window.open( `https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, '_blank' ); } }; const handleDelete = async () => { if (!shop || !user) return; try { // Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09 // delete + a moderation label so the shop is hidden across the app. await deleteEntity({ event: shop.event, reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop', }); setShowDeleteDialog(false); toast({ title: adminTakedown ? 'Shop removed' : 'Shop deleted', description: adminTakedown ? 'This shop has been taken down and will be hidden across SkateSpots.' : 'Deletion request sent to relays. Most relays will remove this content.', }); queryClient.invalidateQueries({ queryKey: ['skate-shops'] }); queryClient.invalidateQueries({ queryKey: ['skate-shop', shop.identifier, shop.pubkey] }); queryClient.invalidateQueries({ queryKey: ['user-skate-shops', user.pubkey] }); setTimeout(() => navigate('/map'), 1000); } catch (error) { toast({ title: 'Delete failed', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); setShowDeleteDialog(false); } }; if (isLoading) { return ; } if (error || !shop) { return ; } // Create a fake SkateSpot for the map component const spotForMap = { ...shop, title: shop.name, types: ['shop'], accessType: undefined, contactInfo: undefined, }; return (
{/* Back link */} Back to Map
{/* Main Content */}
{/* Image Gallery */} {shop.images.length > 0 ? (
{shop.name}
) : (
)} {/* Additional images */} {shop.images.length > 1 && (
{shop.images.slice(1, 5).map((img, idx) => (
{`${shop.name}
))}
)} {/* Shop Info */}

{shop.name}

{isVerifiedOwner && ( Verified )}
{shop.location && (

{shop.location}

)}
{isOwner && ( )} {canDelete && ( )}
{/* Hours */} {shop.hours && (

Hours

{shop.hours}

)} {/* Claim Shop Notice */} {!isOwner && user && (
🏪

Are You the Shop Owner?

If you own or manage this shop, you can claim it to verify ownership using NIP-05.

{hasNip05 ? (

To claim this shop, add a NIP-05 identifier that matches the shop's website domain. For example, if the shop website is supremenewyork.com, your NIP-05 should be you@supremenewyork.com

) : (

⚠️ NIP-05 verification required to claim shops

)}
)} {/* Description */} {shop.description && (

{shop.description}

)} {/* Comments Section */} {shop.event && }
{/* Sidebar */}
{/* Contact Card */} {(shop.phone || shop.email || shop.website || shop.instagram) && ( Contact Shop {sanitizeTel(shop.phone) && ( {shop.phone} )} {sanitizeEmail(shop.email) && ( {shop.email} )} {sanitizeUrl(shop.website) && ( {urlDomain(shop.website!)} )} {shop.instagram && ( @{shop.instagram.replace('@', '')} )} )} {/* Map */} Location
{coordinates && ( )}
{/* Added By Card with Tip the Mapper */} ⚡ Tip the Mapper
{displayName.slice(0, 2).toUpperCase()}

{displayName}

{new Date(shop.createdAt * 1000).toLocaleDateString()}

This shop was added by a community member. Zap them to say thanks!

{/* CLINK self-custodial payment (only when the shop published a valid noffer) */} {shop.noffer && (
)}
{/* Delete Confirmation Dialog */} {adminTakedown ? 'Remove this shop? (admin)' : 'Delete Shop?'} {adminTakedown ? `As a SkateSpots admin you're taking down "${shop.name}". It will be hidden across the app for everyone and a deletion request will be sent to relays.` : `This will permanently delete "${shop.name}" from all relays. This action cannot be undone.`} Cancel {adminTakedown ? 'Remove Shop' : 'Delete Shop'}
); }