SkateSpots/src/pages/ShopDetailPage.tsx
shakespeare.diy feddbaf5ef Add optional CLINK (noffer) self-custodial payments to Lab profiles + shops
Phase 1 CLINK integration (recognize & display only):
- types.ts: optional noffer field + isValidNoffer guard on SkateCoach,
  SkateFilmer, SkateEditor, SkatePhotographer, SkateShop; parsers read it
- NofferField: reusable opt-in input with live validation, added to all 4
  Become-* Lab forms and both Create/Edit shop forms
- ClinkPayButton: secondary "Pay via CLINK" action (QR + copy + nostr: deep
  link), rendered on CoachDetailPage and ShopDetailPage only when a valid
  noffer is present; never replaces the existing zap / Lightning Address flow
- useQRCodeScanner: classify scanned noffer1/ndebit1 CLINK codes
- NIP.md: document the noffer tag on Lab kinds + shops and a CLINK section

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 12:34:50 +07:00

500 lines
20 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 { useNostrPublish } from '@/hooks/useNostrPublish';
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: publishEvent } = useNostrPublish();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const isOwner = user && shop && user.pubkey === shop.pubkey;
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 {
await publishEvent({
kind: 5,
content: 'Deleted shop',
tags: [
['e', shop.id],
['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`],
],
});
setShowDeleteDialog(false);
toast({
title: 'Shop deleted',
description: '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>
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<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" />
Delete Shop?
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
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"
>
Delete Shop
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}