SkateSpots/src/pages/ShopDetailPage.tsx
SkateSpots 127819e3da feat(nip05): multiple handles for profiles, crews and shops
A NIP-05 name maps to one key, so "who owns it" decides where it lives:
- Crew: repeatable nip05 tags (up to 5), verified against the founder key.
- Shop: same on kind 30017, verified against the owner key (new field).
- Profile: kind-0 has ONE standard nip05; extra personal handles live in a
  separate NIP-78 record (kind 30078, d=skatespots:profile-handles) so another
  client re-saving the profile can't wipe them.

Shared pieces:
- components/Nip05Badges.tsx — generic verify-and-render chip row (any owner).
- components/Nip05HandlesEditor.tsx — shared add/remove editor with live
  verified/mismatch status; used by profile, crew and shop forms.
- CrewNip05Badge now delegates to Nip05Badges; CrewForm uses the shared editor.
- Profile page shows primary + extra handles, and each crew/shop chips its own
  verified handle.
- lib/profileHandles.ts + hooks/useProfileHandles.ts (read/publish the record).
- types: SkateShop gains nip05/nip05s; parseSkateShop parses repeatable nip05s.
- NIP.md + AGENTS.md + i18n (EN/ES) updated.
2026-10-03 17:26:59 +07:00

563 lines
23 KiB
TypeScript

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 (
<>
<div className="container px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6 min-w-0">
<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 min-w-0">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</div>
</div>
</>
);
}
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<number | null>(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 <ShopDetailSkeleton />;
}
if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!shop) {
return <NotFound />;
}
// Create a fake SkateSpot for the map component
const spotForMap = {
...shop,
title: shop.name,
types: ['shop'],
accessType: undefined,
contactInfo: undefined,
};
return (
<>
<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" />
{t('placesUi.shop.backToMap')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6 min-w-0">
{/* Image Gallery — click any photo to view full size */}
{shop.images.length > 0 ? (
<button
type="button"
onClick={() => setLightboxIndex(0)}
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
aria-label={t('placesUi.shop.viewPhotoFull')}
>
<ImageWithFallback
src={shop.images[0]}
alt={shop.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
</button>
) : (
<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) => {
const realIndex = idx + 1;
const remaining = shop.images.length - 5;
return (
<button
key={`${img}-${realIndex}`}
type="button"
onClick={() => setLightboxIndex(realIndex)}
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
aria-label={t('placesUi.shop.viewPhotoOf', { index: realIndex + 1, total: shop.images.length })}
>
<ImageWithFallback src={img} alt={`${shop.name} ${realIndex + 1}`} className="w-full h-full object-cover" />
{idx === 3 && remaining > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-sm font-bold text-white">
+{remaining}
</div>
)}
</button>
);
})}
</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 && <ControllerBadge role="verifiedOwner" />}
<OfficialBadge pubkey={shop.pubkey} size="md" />
</div>
{shop.nip05s.length > 0 && (
<Nip05Badges handles={shop.nip05s} pubkey={shop.pubkey} className="mb-1.5" />
)}
{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 ? t('placesUi.shop.removeAdmin') : t('common.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">{t('shops.hours')}</p>
<p className="text-white font-medium">{shop.hours}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Owner: issue &/or review access passes for this venue */}
<VenueAccessCard coord={`${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`} isOwner={!!isOwner} />
{/* 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">{t('placesUi.shop.areYouOwner')}</h3>
<p className="text-sm text-gray-300 mb-4">
{t('placesUi.shop.claimDesc')}
</p>
{hasNip05 ? (
<div>
<p className="text-xs text-gray-400 mb-3">
{t('placesUi.shop.claimNip05Intro')}{' '}
<code className="bg-black/50 px-1 rounded">supremenewyork.com</code>,{' '}
{t('placesUi.shop.claimNip05Your')} <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" />
{t('placesUi.shop.updateNip05')}
</Button>
</Link>
</div>
) : (
<div>
<p className="text-xs text-yellow-400 mb-2">{t('placesUi.shop.nip05Required')}</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
{t('placesUi.shop.addNip05Settings')}
</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 min-w-0">
{/* 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" />
{t('placesUi.shop.contactShop')}
</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={t('placesUi.shop.opens', { domain: 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]" />
{t('placesUi.eventDetail.location')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-square rounded-lg overflow-hidden">
<SpotMap
spots={[spotForMap as unknown as SkateSpot]}
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" />
{t('placesUi.eventDetail.getDirections')}
</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>
{t('placesUi.shop.tipMapper')}
</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(dateLocaleCode())}
</p>
</div>
</div>
<p className="text-xs text-gray-400 mb-3">
{t('placesUi.shop.tipMapperDesc')}
</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 ? t('placesUi.shop.removeDialogTitle') : t('placesUi.shop.deleteDialogTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
{adminTakedown
? t('placesUi.shop.removeDialogDesc', { name: shop.name })
: t('placesUi.shop.deleteDialogDesc', { name: shop.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-600">{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white"
>
{adminTakedown ? t('placesUi.shop.removeShop') : t('placesUi.shop.deleteShop')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* In-app route (no external maps) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={shop?.name ?? 'Shop'}
/>
{/* Full-screen photo viewer */}
<ImageLightbox
images={shop.images.map((url) => ({ url, isOwner: true }))}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</>
);
}