SkateSpots/src/components/ReportSpotStatusDialog.tsx
shakespeare.diy a9e0c1dcb7 Add community Spot Status Reports (kind 2259)
Solves the "spots that change appearance or disappear" problem with
crowd-sourced, trust-weighted status reports — since the spot owner
often isn't the one who notices a spot got knobbed/demolished/busted.

New custom kind 2259 (regular, append-only): any user can report a
spot's current physical state — active / bust / skate-stopped / closed /
gone — with an optional note, referencing the spot via its `a`
coordinate.

Client aggregation (useSpotStatus / useBatchSpotStatus):
- recency-weighted (30-day half-life) so fresh reports dominate and a
  rebuilt spot naturally recovers
- trust-weighted via existing useBatchTrustScores (0.2–1.0 multiplier)
- most-recent-report-per-reporter (no ballot stuffing)
- reports older than 180 days ignored

UI:
- SpotStatusBadge (colored consensus badge + tooltip w/ report count)
- ReportSpotStatusDialog (status picker, optional note, recent reports)
- Badge + Report Status button on SpotDetailPage
- Batched status badge on SpotCard; "gone" spots dimmed/grayscaled
- "gone" spots hidden from map markers (still listed dimmed) to keep the
  map uncluttered while preserving the record

Documented the kind, tags, status values, and aggregation rules in
NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 23:06:10 +07:00

212 lines
7.2 KiB
TypeScript

import { useState } from 'react';
import { Loader2, Flag, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { LoginArea } from '@/components/auth/LoginArea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { usePublishSpotStatus, useSpotStatus } from '@/hooks/useSpotStatus';
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import type { SpotStatusValue, SpotStatusReport } from '@/lib/types';
import { cn } from '@/lib/utils';
interface ReportSpotStatusDialogProps {
spotPubkey: string;
spotIdentifier: string;
spotTitle: string;
trigger?: React.ReactNode;
}
function ReportRow({ report }: { report: SpotStatusReport }) {
const author = useAuthor(report.pubkey);
const name = author.data?.metadata?.name ?? genUserName(report.pubkey);
const meta = SPOT_STATUS_META[report.status];
return (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<span
className="mt-1.5 inline-block rounded-full flex-shrink-0"
style={{ backgroundColor: meta.dot, width: 8, height: 8 }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-semibold">{meta.label}</span>
<span className="text-muted-foreground truncate">by {name}</span>
<span className="text-muted-foreground ml-auto flex-shrink-0">
{statusTimeAgo(report.createdAt)}
</span>
</div>
{report.note && (
<p className="text-xs text-muted-foreground mt-0.5 break-words">{report.note}</p>
)}
</div>
</div>
);
}
export function ReportSpotStatusDialog({
spotPubkey,
spotIdentifier,
spotTitle,
trigger,
}: ReportSpotStatusDialogProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<SpotStatusValue | null>(null);
const [note, setNote] = useState('');
const { aggregated, reports } = useSpotStatus(spotPubkey, spotIdentifier);
const { mutateAsync: publishStatus, isPending } = usePublishSpotStatus();
const handleSubmit = async () => {
if (!selected) return;
try {
await publishStatus({
spotPubkey,
spotIdentifier,
spotTitle,
status: selected,
note: note.trim() || undefined,
});
toast({
title: 'Status reported',
description: 'Thanks for keeping the map fresh. Your report is live.',
});
setSelected(null);
setNote('');
setOpen(false);
} catch (error) {
toast({
title: 'Could not submit',
description: error instanceof Error ? error.message : 'Something went wrong.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm" className="gap-2">
<Flag className="h-4 w-4" />
Report Status
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-[#ff0040]" />
Spot Status
</DialogTitle>
<DialogDescription>
Is "{spotTitle}" still skateable? Let the community know its current state.
</DialogDescription>
</DialogHeader>
{/* Current consensus */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Current:</span>
{aggregated.status ? (
<SpotStatusBadge aggregated={aggregated} />
) : (
<span className="text-muted-foreground italic">No reports yet</span>
)}
</div>
{user ? (
<div className="space-y-4">
{/* Status picker */}
<div className="grid grid-cols-1 gap-2">
{SPOT_STATUS_ORDER.map((value) => {
const meta = SPOT_STATUS_META[value];
const isSel = selected === value;
return (
<button
key={value}
type="button"
onClick={() => setSelected(value)}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left transition-colors',
isSel
? 'border-[#ff0040] bg-[#ff0040]/10'
: 'border-border hover:border-border/80 hover:bg-muted/50',
)}
>
<span
className="inline-block rounded-full flex-shrink-0"
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
/>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
</div>
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
</button>
);
})}
</div>
{/* Optional note */}
<Textarea
placeholder="Optional: add details (e.g. 'new knobs on the ledge', 'guard kicks out after 6pm')"
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
rows={2}
className="resize-none"
/>
<Button
onClick={handleSubmit}
disabled={!selected || isPending}
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting...
</>
) : (
'Submit Report'
)}
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Log in to report this spot's status.
</p>
<LoginArea className="w-full" />
</div>
)}
{/* Recent reports */}
{reports.length > 0 && (
<div className="mt-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
Recent reports
</h4>
<div className="max-h-48 overflow-y-auto">
{reports.slice(0, 20).map((r) => (
<ReportRow key={r.id} report={r} />
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}