The two SkateSpots admin pubkeys (already in src/lib/admins.ts) can now remove any listing, not just their own. Because a NIP-09 deletion signed by a non-author isn't honored by most relays, admin takedowns are made reliable with a client-side moderation layer: - useDeleteEntity: owners still publish a plain NIP-09 kind:5. Admins acting on someone else's content publish the kind:5 AND a NIP-32 kind:1985 `hidden` label referencing the target coordinate/id, authored by the admin. - useAdminModeration (new): fetches admin-authored `hidden` labels app-wide and exposes a fast lookup + filterAdminHidden() helper. Only labels whose author is in the hardcoded ADMIN_PUBKEYS are honored (author = trust boundary, re-verified client-side; never derived from a d-tag). - Wired the hide filter into the list + detail hooks for spots, crews (incl. by-focus), tours, events, and shops via React Query `select`, so removed content disappears from lists and 404s on its detail page. UI: - DeleteEntityDialog now renders for owner OR admin, with distinct "Remove (admin)" framing and copy (used on Crew, Coach, Tour, Event pages). - SpotDetailPage & ShopDetailPage: routed their inline delete through useDeleteEntity, show the delete button for admins, and relabel the confirm dialog for admin takedowns. - Added the delete control to Tour and Event detail pages (previously none). SECURITY.md: documented the admin moderation trust boundary and extended the auditor file map. Verified build + no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
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 (
|
|
<Layout>
|
|
<div className="container px-4 py-8">
|
|
<div className="grid lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<Skeleton className="aspect-video w-full rounded-xl" />
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-10 w-3/4" />
|
|
<Skeleton className="h-6 w-1/2" />
|
|
<Skeleton className="h-24 w-full" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-64 w-full rounded-xl" />
|
|
<Skeleton className="h-32 w-full rounded-xl" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
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 <ShopDetailSkeleton />;
|
|
}
|
|
|
|
if (error || !shop) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
// Create a fake SkateSpot for the map component
|
|
const spotForMap = {
|
|
...shop,
|
|
title: shop.name,
|
|
types: ['shop'],
|
|
accessType: undefined,
|
|
contactInfo: undefined,
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8">
|
|
{/* Back link */}
|
|
<Link
|
|
to="/map"
|
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Map
|
|
</Link>
|
|
|
|
<div className="grid lg:grid-cols-3 gap-8">
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Image Gallery */}
|
|
{shop.images.length > 0 ? (
|
|
<div className="aspect-video rounded-xl overflow-hidden bg-muted">
|
|
<img
|
|
src={shop.images[0]}
|
|
alt={shop.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="aspect-video rounded-xl bg-gradient-to-br from-[#ccff00]/20 to-[#00ff88]/20 flex items-center justify-center">
|
|
<Store className="h-16 w-16 text-[#ccff00]/40" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Additional images */}
|
|
{shop.images.length > 1 && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{shop.images.slice(1, 5).map((img, idx) => (
|
|
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted">
|
|
<img src={img} alt={`${shop.name} ${idx + 2}`} className="w-full h-full object-cover" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Shop Info */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif'
|
|
}}>{shop.name}</h1>
|
|
{isVerifiedOwner && (
|
|
<Badge className="bg-blue-500/20 text-blue-400 border border-blue-500/50">
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
Verified
|
|
</Badge>
|
|
)}
|
|
<OfficialBadge pubkey={shop.pubkey} size="md" />
|
|
</div>
|
|
{shop.location && (
|
|
<p className="text-muted-foreground flex items-center gap-2">
|
|
<Store className="h-4 w-4" />
|
|
{shop.location}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isOwner && (
|
|
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
|
|
<Button variant="outline" size="icon">
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
{canDelete && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
|
className={cn(
|
|
'hover:bg-destructive hover:text-destructive-foreground',
|
|
adminTakedown && 'border-red-500/50 text-red-400',
|
|
)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="icon" onClick={handleShare}>
|
|
<Share2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="outline" size="icon" onClick={handleDirections}>
|
|
<Navigation className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hours */}
|
|
{shop.hours && (
|
|
<Card className="bg-black/90 border-2 border-gray-700">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Clock className="h-5 w-5 text-[#ccff00]" />
|
|
<div>
|
|
<p className="text-sm text-gray-400">Hours</p>
|
|
<p className="text-white font-medium">{shop.hours}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Claim Shop Notice */}
|
|
{!isOwner && user && (
|
|
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-start gap-3">
|
|
<div className="text-2xl">🏪</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-bold text-[#ccff00] mb-2 uppercase">Are You the Shop Owner?</h3>
|
|
<p className="text-sm text-gray-300 mb-4">
|
|
If you own or manage this shop, you can claim it to verify ownership using NIP-05.
|
|
</p>
|
|
{hasNip05 ? (
|
|
<div>
|
|
<p className="text-xs text-gray-400 mb-3">
|
|
To claim this shop, add a NIP-05 identifier that matches the shop's website domain.
|
|
For example, if the shop website is <code className="bg-black/50 px-1 rounded">supremenewyork.com</code>,
|
|
your NIP-05 should be <code className="bg-black/50 px-1 rounded">you@supremenewyork.com</code>
|
|
</p>
|
|
<Link to="/settings">
|
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
|
|
<Shield className="h-4 w-4" />
|
|
Update NIP-05 to Claim
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-xs text-yellow-400 mb-2">⚠️ NIP-05 verification required to claim shops</p>
|
|
<Link to="/settings">
|
|
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
|
Add NIP-05 in Settings
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{shop.description && (
|
|
<Card className="bg-black/90 border-2 border-gray-700">
|
|
<CardContent className="pt-6">
|
|
<p className="whitespace-pre-wrap text-gray-300">{shop.description}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Comments Section */}
|
|
{shop.event && <CommentsSection root={shop.event} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-6">
|
|
{/* Contact Card */}
|
|
{(shop.phone || shop.email || shop.website || shop.instagram) && (
|
|
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2">
|
|
<Phone className="h-5 w-5" />
|
|
Contact Shop
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{sanitizeTel(shop.phone) && (
|
|
<a href={sanitizeTel(shop.phone)!} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
|
<Phone className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="text-gray-300">{shop.phone}</span>
|
|
</a>
|
|
)}
|
|
{sanitizeEmail(shop.email) && (
|
|
<a href={sanitizeEmail(shop.email)!} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
|
<Mail className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="text-gray-300 truncate">{shop.email}</span>
|
|
</a>
|
|
)}
|
|
{sanitizeUrl(shop.website) && (
|
|
<a href={sanitizeUrl(shop.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(shop.website!)}`}>
|
|
<Globe className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="text-gray-300 truncate">{urlDomain(shop.website!)}</span>
|
|
</a>
|
|
)}
|
|
{shop.instagram && (
|
|
<a href={`https://instagram.com/${shop.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
|
<Instagram className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="text-gray-300">@{shop.instagram.replace('@', '')}</span>
|
|
</a>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Map */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Store className="h-5 w-5 text-[#ccff00]" />
|
|
Location
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="aspect-square rounded-lg overflow-hidden">
|
|
<SpotMap
|
|
spots={[spotForMap as any]}
|
|
center={coordinates ? [coordinates.lat, coordinates.lng] : undefined}
|
|
zoom={15}
|
|
className="h-full"
|
|
/>
|
|
</div>
|
|
{coordinates && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full mt-4 gap-2"
|
|
onClick={handleDirections}
|
|
>
|
|
<Navigation className="h-4 w-4" />
|
|
Get Directions
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Added By Card with Tip the Mapper */}
|
|
<Card className="bg-gradient-to-br from-[#ccff00]/5 to-orange-500/5 border-2 border-[#ccff00]/30">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<span className="text-[#ccff00]">⚡</span>
|
|
Tip the Mapper
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Avatar className="h-12 w-12 border-2 border-[#ccff00]/30">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-[#ccff00]/10 text-[#ccff00]">
|
|
{displayName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{displayName}</p>
|
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
|
<Calendar className="h-3 w-3" />
|
|
{new Date(shop.createdAt * 1000).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-400 mb-3">
|
|
This shop was added by a community member. Zap them to say thanks!
|
|
</p>
|
|
|
|
<ZapButton
|
|
target={shop.event}
|
|
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
|
/>
|
|
|
|
{/* CLINK self-custodial payment (only when the shop published a valid noffer) */}
|
|
{shop.noffer && (
|
|
<div className="mt-3">
|
|
<ClinkPayButton
|
|
noffer={shop.noffer}
|
|
recipientName={shop.name}
|
|
className="w-full justify-center"
|
|
/>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent className="bg-black border-2 border-red-500/50">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-white flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
|
{adminTakedown ? 'Remove this shop? (admin)' : 'Delete Shop?'}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-gray-300">
|
|
{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.`}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="border-gray-600">Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
{adminTakedown ? 'Remove Shop' : 'Delete Shop'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Layout>
|
|
);
|
|
}
|