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.
321 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|