SkateSpots/src/lib/spotQuality.ts
SkateSpots f1ddf5fdf1 feat(quality): review queue for every kind, soft photo gate, empty-map nudge
(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.
2026-09-28 09:59:09 +07:00

78 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}