import { dateLocaleCode } from '@/lib/dateFnsI18n'; 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 { PageError } from '@/components/ErrorState'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageLightbox } from '@/components/ImageLightbox'; import { SpotMap } from '@/components/SpotMap'; import { RouteDialog } from '@/components/RouteDialog'; import { SKATE_SHOP_KIND, type SkateSpot } from '@/lib/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { ControllerBadge } from '@/components/ControllerBadge'; import { Nip05Badges } from '@/components/Nip05Badges'; import { VenueAccessCard } from '@/components/VenueAccessCard'; 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, } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/language'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; function ShopDetailSkeleton() { return ( <>
); } export default function ShopDetailPage() { const { t } = useTranslation(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: shop, isLoading, error, refetch } = useShop(identifier || '', pubkey); const author = useAuthor(shop?.pubkey); const { user, metadata: userMetadata } = useCurrentUser(); const { mutateAsync: deleteEntity } = useDeleteEntity(); const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRouteDialog, setShowRouteDialog] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); 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 = userMetadata?.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: t('placesUi.shop.linkCopied'), description: t('placesUi.shop.linkCopiedDesc'), }); } }; const handleDirections = () => { if (coordinates) { // In-app routing — nothing leaves to Google/Apple Maps. setShowRouteDialog(true); } }; 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 ? t('placesUi.shop.adminRemovedReason', { name: shop.name }) : t('placesUi.shop.deletedReason'), }); setShowDeleteDialog(false); toast({ title: adminTakedown ? t('placesUi.shop.removed') : t('placesUi.shop.deleted'), description: adminTakedown ? t('placesUi.shop.removedDesc') : t('placesUi.shop.deletedDesc'), }); queryClient.invalidateQueries({ queryKey: ['skate-shops'] }); queryClient.invalidateQueries({ queryKey: ['skate-shop', shop.identifier, shop.pubkey] }); queryClient.invalidateQueries({ queryKey: ['user-skate-shops', user.pubkey] }); // The legacy directory hook (`useShops`) keeps its own cache namespace. queryClient.invalidateQueries({ queryKey: ['shop-directory'] }); setTimeout(() => navigate('/map'), 1000); } catch (error) { toast({ title: t('placesUi.shop.deleteFailed'), description: error instanceof Error ? error.message : t('placesUi.createEvent.somethingWrong'), variant: 'destructive', }); setShowDeleteDialog(false); } }; if (isLoading) { return ; } if (error) { return refetch()} />; } if (!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 */} {t('placesUi.shop.backToMap')}
{/* Main Content */}
{/* Image Gallery — click any photo to view full size */} {shop.images.length > 0 ? ( ) : (
)} {/* Additional images */} {shop.images.length > 1 && (
{shop.images.slice(1, 5).map((img, idx) => { const realIndex = idx + 1; const remaining = shop.images.length - 5; return ( ); })}
)} {/* Shop Info */}

{shop.name}

{isVerifiedOwner && }
{shop.nip05s.length > 0 && ( )} {shop.location && (

{shop.location}

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

{t('shops.hours')}

{shop.hours}

)} {/* Owner: issue &/or review access passes for this venue */} {/* Claim Shop Notice */} {!isOwner && user && (
🏪

{t('placesUi.shop.areYouOwner')}

{t('placesUi.shop.claimDesc')}

{hasNip05 ? (

{t('placesUi.shop.claimNip05Intro')}{' '} supremenewyork.com,{' '} {t('placesUi.shop.claimNip05Your')} you@supremenewyork.com

) : (

{t('placesUi.shop.nip05Required')}

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

{shop.description}

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

{displayName}

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

{t('placesUi.shop.tipMapperDesc')}

{/* CLINK self-custodial payment (only when the shop published a valid noffer) */} {shop.noffer && (
)}
{/* Delete Confirmation Dialog */} {adminTakedown ? t('placesUi.shop.removeDialogTitle') : t('placesUi.shop.deleteDialogTitle')} {adminTakedown ? t('placesUi.shop.removeDialogDesc', { name: shop.name }) : t('placesUi.shop.deleteDialogDesc', { name: shop.name })} {t('common.cancel')} {adminTakedown ? t('placesUi.shop.removeShop') : t('placesUi.shop.deleteShop')} {/* In-app route (no external maps) */} {/* Full-screen photo viewer */} ({ url, isOwner: true }))} index={lightboxIndex} onIndexChange={setLightboxIndex} onClose={() => setLightboxIndex(null)} /> ); }