(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
(admin-tooling only) to useSkateShops/useSkateEvents/useTours/
useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
(trust/status/photo signals stay spot-only); one component per kind so only the
OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
no photo). Publish without a photo is allowed; the spot stays Unverified, the
success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
(which derives from filteredSpots).
Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
/**
|
||
* Cheap, explainable "is this pin junk?" signals.
|
||
*
|
||
* Shared by the admin review queue and its Settings card so the two never drift.
|
||
* These are HEURISTICS, not verdicts: every one of them is something a human can
|
||
* check in about two seconds, and the action they drive (a reversible NIP-32
|
||
* label) is a suggestion-taking, never a deletion.
|
||
*/
|
||
import type { SkateSpot } from '@/lib/types';
|
||
|
||
/** Names that almost always mean a test entry or an off-topic pin. */
|
||
const SUSPICIOUS_TITLE =
|
||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
||
|
||
/** The minimum any reviewable entity can tell us (any kind). */
|
||
export interface ContentQualityInput {
|
||
title?: string;
|
||
description?: string;
|
||
/** How many pictures the entity carries (0 = none). */
|
||
imageCount?: number;
|
||
/** Whether it has a usable location (spots/shops/events/tours). */
|
||
hasLocation?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Kind-agnostic junk signals, so the review queue can cover shops, events,
|
||
* tours and listings without a copy of the logic per kind.
|
||
*/
|
||
export function contentFlags(input: ContentQualityInput): string[] {
|
||
const flags: string[] = [];
|
||
const title = (input.title ?? '').trim();
|
||
const description = (input.description ?? '').trim();
|
||
|
||
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
|
||
if (title.length <= 3) flags.push('name too short');
|
||
if ((input.imageCount ?? 0) === 0) flags.push('no photo');
|
||
if (description.length < 10) flags.push('no description');
|
||
if (input.hasLocation === false) flags.push('no location');
|
||
|
||
return flags;
|
||
}
|
||
|
||
export interface SpotQualitySignals {
|
||
/** Mapper's web-of-trust score (0–100), when known. */
|
||
mapperTrust?: number;
|
||
/** Status reports (kind 2259) from skaters — a real "someone was here" signal. */
|
||
communityStatusReports?: number;
|
||
/** Community photos contributed by people other than the mapper. */
|
||
communityPhotos?: number;
|
||
}
|
||
|
||
/** Every flag that applies, as short human-readable labels. */
|
||
export function spotFlags(spot: SkateSpot, extra: SpotQualitySignals = {}): string[] {
|
||
const flags = contentFlags({
|
||
title: spot.title,
|
||
description: spot.description,
|
||
imageCount: spot.images?.length ?? 0,
|
||
hasLocation: !!spot.geohash,
|
||
});
|
||
|
||
// Signals that need extra data (passed in by the review queue).
|
||
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {
|
||
flags.push('mapper not in your web of trust');
|
||
}
|
||
const communitySignals =
|
||
(extra.communityStatusReports ?? 0) + (extra.communityPhotos ?? 0);
|
||
if (communitySignals === 0 && flags.length > 0) {
|
||
// Only meaningful alongside another flag: an unconfirmed spot with a real
|
||
// name and a photo is just new, not junk.
|
||
flags.push('no community signals');
|
||
}
|
||
|
||
return flags;
|
||
}
|
||
|
||
export function isFlagged(spot: SkateSpot): boolean {
|
||
return spotFlags(spot).length > 0;
|
||
}
|