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.
This commit is contained in:
SkateSpots 2026-09-27 23:41:49 +07:00
parent 0adfbcbd29
commit a43ff47b28
13 changed files with 529 additions and 7 deletions

View file

@ -367,6 +367,29 @@ session with **no other memory of this project**. So:
SVGs are self-contained: brand fonts + logo embedded as data URIs and a real
vector QR (verified decoding to https://skate-spots.org — if you regenerate,
re-verify the QR scans with the logo in the middle; error-correction level H).
- **Spot data quality (2026-09-27):** three cheap defences against junk pins
(test entries, airports, malls), because "the map looks endorsed" is the real
risk. (1) `CreateSpotPage` **requires ≥1 photo** (guard in `handleSubmit` +
the submit button is disabled) — a typo rarely has a photo. (2) The spot page
shows a **Community verified / Unverified** badge computed from signals only
*other* skaters can produce: community photos + `useSpotStatus` `reporterCount`
(kind 2259). The weather score was renamed **"Conditions · weather only"**
(in `SpotDetailPage` and the `SpotMap` popup) so it can't read as spot quality.
(3) `useNotASpotReport` = one-tap **NIP-56 (1984)** report tagged
`not-a-skate-spot` (NIP-56 `other` reason + our `L`/`l`/`t` taxonomy), used
from the spot's "More" menu and the shared `ReportDialog`.
- **Admin hide + review queue (2026-09-27):** `useAdminModeration` always READ
NIP-32 kind-1985 `hidden` labels, but **nothing published one** — the only
working hide was device-local (`useDeleteEntity` → `addLocalHidden`).
`useAdminHide` is the missing write side: kind 1985 + `['L',
'org.skatespots.moderation']` + `['l','hidden',NS]` + `['a',coord]`/`['e',id]`,
plus an optimistic local hide. `/review` (`AdminReviewPage`, **admin-only,
404 otherwise**) lists spots that trip cheap signals (suspicious title, name
≤3 chars, no photo, no description, no geohash) sorted by flag count, with
one-tap **Hide** or **Not a skate spot**; entry point is the admin-only
`AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
the event stays on relays (honest for a censorship-resistant network); nothing
is deleted. `/review` is in the smoke-test route list.
- **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

View file

@ -1,5 +1,16 @@
v1.5.4
🧹 Keeping the map real
- Every new spot now needs at least one photo — the cheapest defence against
test entries and off-topic pins ("test of secret island airport").
- Spots only say "Community verified" when another skater has contributed (a
photo or a status report). Until then they are labelled "Unverified" instead
of looking endorsed.
- The weather score is now labelled "Conditions · weather only". It was never a
verdict on whether a place is a real spot.
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
private land), plus an admin review queue that surfaces them for a human.
🛍 App Shop — a new section (empty on purpose, with free printables)
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
and an A3 poster, both print-ready vector files with a working QR.

View file

@ -141,6 +141,7 @@ const ROUTES = [
'/stats',
'/mints',
'/app-shop',
'/review',
'/messages/group/abc123',
];
const problems = [];

View file

@ -18,6 +18,7 @@ const LabPage = lazyWithRetry(() => import("./pages/LabPage"));
const WalletPage = lazyWithRetry(() => import("./pages/WalletPage"));
const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@ -136,6 +137,7 @@ export function AppRouter() {
<Route path="/wallet" element={<WalletPage />} />
<Route path="/mints" element={<MintsPage />} />
<Route path="/app-shop" element={<AppShopPage />} />
<Route path="/review" element={<AdminReviewPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-shop" element={<CreateShopPage />} />

View file

@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import { useMemo } from 'react';
import { ShieldAlert, ArrowRight } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { isAdminPubkey } from '@/lib/admins';
import type { SkateSpot } from '@/lib/types';
/** Same cheap signals the review page uses (kept in sync deliberately). */
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
function isFlagged(spot: SkateSpot): boolean {
const title = (spot.title ?? '').trim();
const description = (spot.description ?? '').trim();
return (
SUSPICIOUS_TITLE.test(title) ||
title.length <= 3 ||
!spot.images?.length ||
description.length < 10
);
}
/**
* Admin-only entry point for the review queue. Renders nothing for everyone
* else, so it can sit in Settings → Advanced beside the other owner cards.
*/
export function AdminReviewCard() {
const { user } = useCurrentUser();
const isAdmin = isAdminPubkey(user?.pubkey);
const { data: spots } = useSkateSpots(300);
const flaggedCount = useMemo(
() => (spots ?? []).filter(isFlagged).length,
[spots],
);
if (!isAdmin) return null;
return (
<Link
to="/review"
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
>
<div className="flex items-center gap-3 px-4 py-3.5">
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-amber-400">Review queue</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{flaggedCount > 0
? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.`
: 'Spots that look like test entries or off-topic pins.'}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
</div>
</Link>
);
}

View file

@ -18,6 +18,13 @@ 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',

View file

@ -580,9 +580,9 @@ function createPopupContent(
</div>
<div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${escapeHtml(weatherData.skateability.label)}
Conditions · ${escapeHtml(weatherData.skateability.label)}
</div>
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100</div>
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100 weather</div>
</div>
`;
weatherSection.appendChild(currentWeather);

61
src/hooks/useAdminHide.ts Normal file
View file

@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { addLocalHidden } from '@/lib/localModeration';
import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN } from '@/lib/types';
/**
* Publish an admin "hidden" moderation label (NIP-32 kind 1985).
*
* THE MISSING HALF: `useAdminModeration` has always READ these labels and
* filtered hidden content out of every list — but nothing ever PUBLISHED one, so
* the only working hide was the local, this-device-only one in
* `useDeleteEntity`. This hook is the write side: the label is public, authored
* by an admin pubkey, and any SkateSpots client (and any other client that
* honours the namespace) will filter the target. It is a **soft, reversible
* hide** — the underlying event stays on relays, which is the honest behaviour
* on a censorship-resistant network.
*
* Also writes the OPTIMISTIC local hide so the content disappears instantly on
* this device instead of waiting for the relay round-trip.
*/
export function useAdminHide() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async ({
coord,
id,
reason,
}: {
/** Addressable coordinate, e.g. `30818:<pubkey>:<d>`. */
coord?: string;
/** Event id (for regular/replaceable events). */
id?: string;
/** Why it was hidden — stored in the label's content for transparency. */
reason?: string;
}) => {
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
if (!coord && !id) throw new Error('Nothing to hide — need a coordinate or an event id.');
const tags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
['alt', 'SkateSpots: moderation label — hidden'],
];
if (coord) tags.push(['a', coord]);
if (id) tags.push(['e', id]);
await publish({ kind: 1985, content: reason ?? '', tags });
// Optimistic: hide on this device immediately.
addLocalHidden({ coord, id });
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
},
});
return { hideContent: mutation.mutateAsync, isPending: mutation.isPending || publishing };
}

View file

@ -0,0 +1,72 @@
import { useMutation } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
/**
* One-tap "this is not a skate spot" report.
*
* WHY NIP-56 AND NOT A NEW KIND: kind 1984 is the ecosystem's report event and
* `other` is its catch-all reason, so the report is useful to *any* Nostr client
* (and visible on relays) instead of being a SkateSpots-only artifact. The extra
* `L`/`l`/`t` tags give us a stable taxonomy (`not-a-skate-spot`) that our own
* admin tooling can filter on without breaking anyone else's interpretation.
*
* This is for OFF-TOPIC places (test entries, an airport, a mall, private land) —
* not for disagreeing about whether a spot is good.
*/
export function useNotASpotReport() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
const { toast } = useToast();
const mutation = useMutation({
mutationFn: async ({
eventId,
pubkey,
title,
}: {
/** The spot's kind-30818 event id. */
eventId: string;
/** The spot's author. */
pubkey: string;
title?: string;
}) => {
if (!user) throw new Error('Log in to report a spot.');
await publish({
kind: 1984, // NIP-56 report
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
tags: [
['e', eventId, 'other'],
['p', pubkey],
['L', MODERATION_LABEL_NAMESPACE],
['l', 'not-a-skate-spot', MODERATION_LABEL_NAMESPACE],
['t', 'not-a-skate-spot'],
['alt', 'SkateSpots: report — not a skate spot'],
],
});
},
onSuccess: () => {
toast({
title: 'Thanks — sent for review',
description:
'No hard feelings: this flags the pin as off-topic so people can decide for themselves.',
});
},
onError: (error) => {
toast({
title: 'Could not send the report',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
},
});
return {
reportNotASpot: mutation.mutateAsync,
isPending: mutation.isPending || publishing,
alreadyReported: mutation.isSuccess,
};
}

View file

@ -0,0 +1,194 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ShieldAlert, Flag, EyeOff, ExternalLink, Loader2, CheckCircle2 } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useAdminHide } from '@/hooks/useAdminHide';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import NotFound from '@/pages/NotFound';
/**
* Admin review queue.
*
* Surfaces spots that LOOK like junk using cheap, explainable signals — the kind
* of thing a test entry or an off-topic pin trips instantly ("test of secret
* island airport": suspicious name + no photo). It does not delete anything:
* every action is a soft, reversible NIP-32 `hidden` label (see `useAdminHide`)
* or a NIP-56 report. Flags are suggestions, not verdicts — a human decides.
*/
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
/** Cheap signals. Each one is something a human can check in two seconds. */
function flagsFor(spot: SkateSpot): string[] {
const flags: string[] = [];
const title = (spot.title ?? '').trim();
const description = (spot.description ?? '').trim();
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
if (title.length <= 3) flags.push('name too short');
if (!spot.images || spot.images.length === 0) flags.push('no photo');
if (description.length < 10) flags.push('no description');
if (!spot.geohash) flags.push('no location precision');
return flags;
}
export default function AdminReviewPage() {
const { user } = useCurrentUser();
const { data: spots, isLoading } = useSkateSpots(300);
const { hideContent, isPending: hiding } = useAdminHide();
const { reportNotASpot } = useNotASpotReport();
const { toast } = useToast();
const flagged = useMemo(
() =>
(spots ?? [])
.map((spot) => ({ spot, flags: flagsFor(spot) }))
.filter((row) => row.flags.length > 0)
.sort((a, b) => b.flags.length - a.flags.length),
[spots],
);
useSeoMeta({ title: 'Review queue — SkateSpots' });
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
const handleHide = async (spot: SkateSpot, flags: string[]) => {
try {
await hideContent({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
reason: `auto-flagged: ${flags.join(', ')}`,
});
toast({
title: 'Hidden',
description: 'Removed from the app via a public moderation label. Reversible.',
});
} catch (e) {
toast({
title: 'Could not hide',
description: e instanceof Error ? e.message : 'Try again.',
variant: 'destructive',
});
}
};
const handleNotASpot = (spot: SkateSpot) => {
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
};
return (
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShieldAlert}
title="Review queue"
accent="#ff6600"
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label — nothing is deleted."
/>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
) : flagged.length === 0 ? (
<Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
Nothing to review — no spot currently trips the junk signals.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{flagged.map(({ spot, flags }) => (
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
{spot.images?.[0] ? (
<img
src={spot.images[0]}
alt=""
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
🛹
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-bold text-white">{spot.title || '(untitled)'}</p>
<p className="truncate text-xs text-gray-500">
{spot.description?.trim() || 'no description'}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{flags.map((f) => (
<Badge
key={f}
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
>
{f}
</Badge>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
onClick={() => void handleHide(spot, flags)}
>
{hiding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
Hide
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
onClick={() => handleNotASpot(spot)}
>
<Flag className="h-3.5 w-3.5" />
Not a skate spot
</Button>
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
Hiding publishes a public NIP-32 label (<code>org.skatespots.moderation</code> /
<code> hidden</code>) that every SkateSpots client filters out; the spot itself stays on
relays, so it can be un-hidden later. Flags are heuristics — check before you hide.
</p>
</div>
);
}

View file

@ -251,6 +251,20 @@ export default function CreateSpotPage() {
return;
}
// A photo is REQUIRED. It is the single cheapest defence against the map
// filling up with test entries and off-topic pins ("test of secret island
// airport"): a real spot has a photo, a typo doesn't. It also gives every
// other skater something to verify against.
if (images.length === 0) {
toast({
title: 'Add a photo first',
description:
'Every spot needs at least one photo — of the spot, not the map. It keeps the map real.',
variant: 'destructive',
});
return;
}
// Don't publish while the Quick Snap photo is still uploading — otherwise the
// spot would go out without the photo the user just took.
if (isUploadingQuickPhoto) {
@ -682,9 +696,15 @@ export default function CreateSpotPage() {
/>
</div>
{/* Images */}
{/* Images — required (see the guard in handleSubmit). */}
<div className="space-y-2">
<Label>{t('spots.photos')}</Label>
<Label>
{t('spots.photos')}
<span className="ml-1 text-red-400">*</span>
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
required — one photo of the spot keeps the map honest
</span>
</Label>
<div className="grid grid-cols-3 gap-4">
{images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
@ -749,7 +769,7 @@ export default function CreateSpotPage() {
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
disabled={isPublishing || isUploading || isUploadingQuickPhoto || images.length === 0}
>
{isPublishing ? (
<>

View file

@ -9,6 +9,7 @@ import { SecuritySettings } from '@/components/SecuritySettings';
import { RelatrSettings } from '@/components/RelatrSettings';
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { AdminReviewCard } from '@/components/AdminReviewCard';
import { LanguageSelector } from '@/components/LanguageToggle';
import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab';
@ -590,6 +591,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
{/* Owner-only — render null for everyone except platform owners. */}
<CVMHealthCheck />
<SasquatchAiStatusCard />
<AdminReviewCard />
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
<div className="px-4 py-3.5 border-b border-white/5">
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>

View file

@ -20,6 +20,7 @@ import { RouteDialog } from '@/components/RouteDialog';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { useAuthor } from '@/hooks/useAuthor';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
@ -53,6 +54,8 @@ import {
Flag,
MoreHorizontal,
MessageCircle,
ShieldCheck,
ShieldQuestion,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ReportDialog } from '@/components/ReportDialog';
@ -125,6 +128,17 @@ export default function SpotDetailPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
// One-tap off-topic report (test entries, airports, malls, private land…).
const {
reportNotASpot,
isPending: reportNotASpotPending,
alreadyReported,
} = useNotASpotReport();
const handleNotASpot = () => {
if (!spot) return;
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
};
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [showRouteDialog, setShowRouteDialog] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -144,6 +158,16 @@ export default function SpotDetailPage() {
// Community spot status (active / bust / skate-stopped / closed / gone)
const { aggregated: spotStatus } = useSpotStatus(pubkey || '', identifier || '');
/**
* Community confidence — a signal INDEPENDENT of the weather score, so a
* test/off-topic entry can't look endorsed just because the sun is out.
* Counts things only other skaters can produce: a community photo, or a
* status report ("I was here / bust / skate-stopped").
*/
const communitySignalCount =
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
const communityVerified = communitySignalCount > 0;
useSeoMeta({
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
@ -406,6 +430,19 @@ export default function SpotDetailPage() {
Report {isShop ? 'Shop' : 'Spot'}
</DropdownMenuItem>
)}
{/* One tap: this pin is off-topic (test entry, airport,
mall, private land…). Publishes a NIP-56 report with a
`not-a-skate-spot` tag for the admin queue. */}
{!isOwner && !isShop && (
<DropdownMenuItem
onClick={handleNotASpot}
disabled={reportNotASpotPending || alreadyReported}
className="text-amber-500 focus:text-amber-500"
>
<Flag className="h-4 w-4 mr-2" />
{alreadyReported ? 'Reported as not a skate spot' : 'Not a skate spot'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -437,6 +474,33 @@ export default function SpotDetailPage() {
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
</Badge>
)}
{/* Community confidence — NOT a quality/weather score. */}
<Badge
className={cn(
'border font-bold uppercase',
communityVerified
? 'border-emerald-500/50 bg-emerald-500/20 text-emerald-400'
: 'border-white/15 bg-white/5 text-gray-500',
)}
title={
communityVerified
? 'Another skater has contributed here (photo or status report).'
: 'No community photo or status report yet — nobody has confirmed this place.'
}
>
{communityVerified ? (
<>
<ShieldCheck className="mr-1 h-3 w-3" />
Community verified
{communitySignalCount > 1 && ` · ${communitySignalCount}`}
</>
) : (
<>
<ShieldQuestion className="mr-1 h-3 w-3" />
Unverified
</>
)}
</Badge>
{spot.types.map((type) => (
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
{SPOT_TYPE_LABELS[type] || type}
@ -814,11 +878,17 @@ export default function SpotDetailPage() {
</div>
</div>
{/* Skateability Score */}
{/* Conditions score — WEATHER ONLY. Deliberately not called
"Skateability": this is about the sky, not whether the
place is a real, rideable spot. The community signal lives
in the badge next to the spot's type tags. */}
<div className="p-3 rounded-lg" style={{ backgroundColor: `${weatherData.skateability.color}20`, border: `2px solid ${weatherData.skateability.color}` }}>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
Skateability
Conditions
<span className="ml-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
weather only
</span>
</span>
<span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}>
{weatherData.skateability.score}/100