SkateSpots/src/components/ReportDialog.tsx
SkateSpots a43ff47b28 feat(spots): honest quality signals, photo required, off-topic reports, admin review queue
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed
'Skateability 95/100'.

- Rename the weather score to 'Conditions · weather only' (SpotDetailPage +
  the SpotMap popup). It was never a verdict on the place.
- New Community verified / Unverified badge from signals only OTHER skaters can
  produce: community photos + useSpotStatus reporterCount (kind 2259).
- CreateSpotPage now REQUIRES at least one photo (guard + disabled submit +
  a required hint on the label).
- useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot'
  (NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and
  added to the shared ReportDialog reasons.
- useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration
  always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the
  only working hide was device-local. Now publishes the label (+ optimistic
  local hide). Soft and reversible — the event stays on relays.
- /review (AdminReviewPage, admin-only → 404 otherwise): review queue over
  useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no
  description, no geohash), sorted by flag count, one-tap Hide or Not a skate
  spot. Entry via the admin-only AdminReviewCard in Settings → Advanced.
- /review added to the smoke-test route list.

Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and
no 'Skateability'; the review queue lists it with flags + Hide/report actions;
create-spot blocks without a photo.
2026-09-27 23:41:49 +07:00

321 lines
9.9 KiB
TypeScript

import { useState } from 'react';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// NIP-56 Report types with skate-specific labels
const REPORT_REASONS = [
{
id: 'other',
label: 'Not a skate spot',
description: 'A test entry, or a place that is not for skating at all',
icon: '🛹',
subtype: 'not-a-skate-spot',
},
{
id: 'spam',
label: 'Fake/Non-existent spot',
description: 'This spot does not exist or location is wrong',
icon: '📍',
},
{
id: 'other',
label: 'Stolen/Fake photos',
description: 'Photos are stock images or stolen from elsewhere',
icon: '📸',
},
{
id: 'spam',
label: 'Spam or advertising',
description: 'Promotional content or duplicate listings',
icon: '🗑️',
subtype: 'spam-ad',
},
{
id: 'illegal',
label: 'Dangerous location',
description: 'Private property, unsafe, or illegal to access',
icon: '⚠️',
},
{
id: 'nudity',
label: 'Inappropriate content',
description: 'Contains offensive or adult content',
icon: '🔞',
},
{
id: 'impersonation',
label: 'Impersonation',
description: 'Someone pretending to be someone else',
icon: '👤',
},
{
id: 'other',
label: 'Other',
description: 'Something else not listed above',
icon: '📝',
subtype: 'other-general',
},
] as const;
interface ReportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// What we're reporting
targetType: 'spot' | 'event' | 'tour' | 'shop' | 'user' | 'listing' | 'coach' | 'filmer';
targetPubkey: string;
targetEventId?: string;
targetTitle?: string;
// Callback after report
onReported?: () => void;
}
export function ReportDialog({
open,
onOpenChange,
targetType,
targetPubkey,
targetEventId,
targetTitle,
onReported,
}: ReportDialogProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [selectedReason, setSelectedReason] = useState<string>('');
const [additionalDetails, setAdditionalDetails] = useState('');
const [step, setStep] = useState<'form' | 'success'>('form');
const handleSubmit = async () => {
if (!user || !selectedReason) return;
const reason = REPORT_REASONS.find(
(r) => r.id === selectedReason || ('subtype' in r && r.subtype === selectedReason)
);
if (!reason) return;
try {
// Build NIP-56 report tags
const tags: string[][] = [];
// Always include the pubkey being reported
if (targetEventId) {
// Reporting an event (spot, tour, etc.)
tags.push(['e', targetEventId, reason.id]);
tags.push(['p', targetPubkey]);
} else {
// Reporting just a user/profile
tags.push(['p', targetPubkey, reason.id]);
}
// Build content with context
let content = '';
if (additionalDetails.trim()) {
content = additionalDetails.trim();
}
if (targetTitle) {
content = content
? `${content}\n\nReported: ${targetTitle} (${targetType})`
: `Reported: ${targetTitle} (${targetType})`;
}
await publishEvent({
kind: 1984, // NIP-56 Report
content,
tags,
});
setStep('success');
onReported?.();
toast({
title: 'Report submitted',
description: 'Thank you for helping keep SkateSpots safe.',
});
} catch (error) {
toast({
title: 'Failed to submit report',
description: error instanceof Error ? error.message : 'Please try again',
variant: 'destructive',
});
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form after animation
setTimeout(() => {
setSelectedReason('');
setAdditionalDetails('');
setStep('form');
}, 200);
};
const getReasonKey = (reason: typeof REPORT_REASONS[number]) => {
return ('subtype' in reason ? reason.subtype : undefined) || reason.id;
};
if (!user) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" />
Report Content
</DialogTitle>
</DialogHeader>
<div className="text-center py-6">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
<p className="text-muted-foreground">
You need to be logged in to report content.
</p>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" />
Report Content
</DialogTitle>
{step === 'form' && (
<DialogDescription>
Help keep the community safe by reporting inappropriate content.
</DialogDescription>
)}
</DialogHeader>
{step === 'form' ? (
<div className="space-y-4">
{/* Target info */}
{targetTitle && (
<div className="p-3 rounded-lg bg-muted/50 border">
<p className="text-xs text-muted-foreground uppercase mb-1">
Reporting {targetType}
</p>
<p className="font-medium text-sm truncate">{targetTitle}</p>
</div>
)}
{/* Reason selection */}
<div className="space-y-2">
<Label>Why are you reporting this?</Label>
<RadioGroup
value={selectedReason}
onValueChange={setSelectedReason}
className="space-y-2"
>
{REPORT_REASONS.map((reason) => {
const key = getReasonKey(reason);
return (
<div
key={key}
className={cn(
'flex items-start space-x-3 p-3 rounded-lg border transition-colors cursor-pointer',
selectedReason === key
? 'border-red-500/50 bg-red-500/10'
: 'border-border hover:bg-muted/50'
)}
onClick={() => setSelectedReason(key)}
>
<RadioGroupItem value={key} id={key} className="mt-0.5" />
<div className="flex-1 min-w-0">
<Label
htmlFor={key}
className="flex items-center gap-2 cursor-pointer font-medium"
>
<span>{reason.icon}</span>
{reason.label}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{reason.description}
</p>
</div>
</div>
);
})}
</RadioGroup>
</div>
{/* Additional details */}
<div className="space-y-2">
<Label htmlFor="details">Additional details (optional)</Label>
<Textarea
id="details"
placeholder="Provide any additional context that might help..."
value={additionalDetails}
onChange={(e) => setAdditionalDetails(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
{/* Submit */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedReason || isPending}
className="flex-1 bg-red-500 hover:bg-red-600"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Flag className="h-4 w-4 mr-2" />
Submit Report
</>
)}
</Button>
</div>
</div>
) : (
/* Success state */
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h3 className="font-bold text-lg mb-2">Report Submitted</h3>
<p className="text-muted-foreground text-sm mb-6">
Thanks for helping keep SkateSpots safe and accurate. Your report
will be reviewed by trusted community members.
</p>
<Button onClick={handleClose} className="w-full">
Done
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}