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)}
/>
>
);
}