feat(spots): reports reach the admin, duplicates are caught, contributors are told
1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader in the app was a count on marketplace cards, so an admin never saw one. Now useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and the review queue ranks reported spots ABOVE the heuristics with a red 'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays() (community relay + the reporter's), not the default pool — the admin reads from the community relay, so reports written only to personal relays would never arrive. Verified end-to-end: published a report from Node, saw it surface in the queue. 2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are listed with distances + links, with 'It's a different spot — publish anyway'. Targets the clone-spam failure mode (five near-identical pins). 3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its AUTHOR (it used to silently 404 for the person who made it), and SpotDetailPage shows an author-only banner for hidden/verified. tsc/eslint clean, 101 tests, smoke PASS.
This commit is contained in:
parent
e44f8b81a7
commit
e549aeee3a
8 changed files with 273 additions and 22 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -417,6 +417,27 @@ session with **no other memory of this project**. So:
|
|||
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||||
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||||
so both verbiage and verdicts are reversible in the UI.
|
||||
- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):**
|
||||
three gaps closed in one pass.
|
||||
1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/
|
||||
events/tours/users/listings/comments, but the ONLY reader in the app was a
|
||||
count on marketplace cards — an admin never saw a report. Now
|
||||
**`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE
|
||||
query (via `querySkateContent`), and the review queue ranks reported spots
|
||||
ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now
|
||||
publishes to **`getWriteRelays()`** (community relay + the reporter's) rather
|
||||
than the default pool, because the admin reads from the community relay — the
|
||||
same relay-parity rule as the newsletter and DMs. Verified end-to-end by
|
||||
publishing a report from Node and seeing it surface in the queue.
|
||||
2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within
|
||||
**60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them
|
||||
with distances and links, plus "It's a different spot — publish anyway"
|
||||
(`dupConfirmedRef` bypass). Designed for the clone-spam failure mode.
|
||||
3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden
|
||||
spot when the viewer is its **author** — otherwise a moderated spot silently
|
||||
404s for the person who made it. `SpotDetailPage` shows an author-only banner
|
||||
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
|
||||
labels, so there is nothing secret being revealed.
|
||||
- **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/* →
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ v1.5.4
|
|||
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
|
||||
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
|
||||
spot to neutral without hiding or vouching for it.
|
||||
- **Reports now reach a human**: the review queue surfaces reported spots at the
|
||||
very top, above the automated signals — so "not a skate spot" actually lands.
|
||||
- **Adding a spot warns you if one already exists** within 60 m, and links you
|
||||
straight to it instead of letting you create a duplicate.
|
||||
- **If a moderator hides or vouches for your spot, you're told** — right there on
|
||||
the spot page (your own spots stay visible to you either way).
|
||||
|
||||
🛍 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
||||
|
|
@ -18,7 +19,8 @@ import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
|||
*/
|
||||
export function useNotASpotReport() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
|
||||
const { nostr } = useNostr();
|
||||
const { getWriteRelays } = useSkateRelays();
|
||||
const { toast } = useToast();
|
||||
|
||||
const mutation = useMutation({
|
||||
|
|
@ -35,9 +37,10 @@ export function useNotASpotReport() {
|
|||
}) => {
|
||||
if (!user) throw new Error('Log in to report a spot.');
|
||||
|
||||
await publish({
|
||||
const event = await user.signer.signEvent({
|
||||
kind: 1984, // NIP-56 report
|
||||
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['e', eventId, 'other'],
|
||||
['p', pubkey],
|
||||
|
|
@ -47,6 +50,18 @@ export function useNotASpotReport() {
|
|||
['alt', 'SkateSpots: report — not a skate spot'],
|
||||
],
|
||||
});
|
||||
|
||||
// Publish to the COMMUNITY relay as well as the reporter's own relays.
|
||||
// Admins read reports from the community relay, so a report written only
|
||||
// to the reporter's personal relays would never reach them.
|
||||
const results = await Promise.allSettled(
|
||||
getWriteRelays().map((url) =>
|
||||
nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }),
|
||||
),
|
||||
);
|
||||
if (results.every((r) => r.status === 'rejected')) {
|
||||
throw new Error('Could not reach any relay — check your connection and try again.');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
|
|
@ -66,7 +81,7 @@ export function useNotASpotReport() {
|
|||
|
||||
return {
|
||||
reportNotASpot: mutation.mutateAsync,
|
||||
isPending: mutation.isPending || publishing,
|
||||
isPending: mutation.isPending,
|
||||
alreadyReported: mutation.isSuccess,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
||||
|
||||
|
|
@ -73,13 +74,20 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean }
|
|||
export function useSkateSpot(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (spot: SkateSpot | null) =>
|
||||
spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
|
||||
? null
|
||||
: spot,
|
||||
select: (spot: SkateSpot | null) => {
|
||||
if (!spot) return null;
|
||||
if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) {
|
||||
return spot;
|
||||
}
|
||||
// A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot
|
||||
// silently 404s and they never learn why. Everyone else gets null (the map
|
||||
// and lists filter it too). The detail page shows them what happened.
|
||||
return user?.pubkey === spot.pubkey ? spot : null;
|
||||
},
|
||||
queryFn: async () => {
|
||||
// If offline, try to get from cache first
|
||||
if (isOffline() && pubkey) {
|
||||
|
|
|
|||
68
src/hooks/useSpotReports.ts
Normal file
68
src/hooks/useSpotReports.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
|
||||
/** NIP-56 report kind. */
|
||||
export const REPORT_KIND = 1984;
|
||||
|
||||
export interface SpotReportCounts {
|
||||
/** Every NIP-56 report referencing the spot. */
|
||||
total: number;
|
||||
/** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */
|
||||
notASpot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-read NIP-56 reports for many spots in ONE query.
|
||||
*
|
||||
* Reports carry the target's event id in an `e` tag, so asking for
|
||||
* `#e: [all spot ids]` is a single relay request — the same trick as the status
|
||||
* and photo batch hooks.
|
||||
*
|
||||
* WHY THIS EXISTS: people could already report a spot, but nothing ever brought
|
||||
* those reports to the admin — the only reader in the whole app was a count on
|
||||
* marketplace cards. A report button whose output nobody sees is decoration, so
|
||||
* the review queue now ranks reported spots to the top.
|
||||
*/
|
||||
export function useSpotReports(spots: Array<{ id: string }>) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
const ids = useMemo(() => spots.map((s) => s.id).filter(Boolean), [spots]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['spot-reports', ids.slice().sort().join(','), getQueryKeySuffix()],
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 60_000,
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||
return querySkateContent(
|
||||
[{ kinds: [REPORT_KIND], '#e': ids, limit: 1000 }],
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = new Map<string, SpotReportCounts>();
|
||||
for (const ev of query.data ?? []) {
|
||||
// A report can reference several targets; count each.
|
||||
for (const [n, value] of ev.tags) {
|
||||
if (n !== 'e' || !value) continue;
|
||||
const entry = map.get(value) ?? { total: 0, notASpot: 0 };
|
||||
entry.total += 1;
|
||||
// Our own taxonomy marker, or the NIP-56 'other' reason from the dialog.
|
||||
const isNotASpot =
|
||||
ev.tags.some(([t, v]) => t === 't' && v === 'not-a-skate-spot') ||
|
||||
ev.tags.some(([t, v]) => t === 'l' && v === 'not-a-skate-spot');
|
||||
if (isNotASpot) entry.notASpot += 1;
|
||||
map.set(value, entry);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
isLoading: query.isLoading,
|
||||
getCounts: (id: string): SpotReportCounts => counts.get(id) ?? { total: 0, notASpot: 0 },
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
||||
import { useSpotReports } from '@/hooks/useSpotReports';
|
||||
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
|
||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
|
|
@ -69,6 +70,8 @@ export default function AdminReviewPage() {
|
|||
);
|
||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||
// What people actually reported — the strongest signal we have.
|
||||
const { getCounts: getReportCounts } = useSpotReports(spots ?? []);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
|
|
@ -80,6 +83,15 @@ export default function AdminReviewPage() {
|
|||
const verified = moderation.isVerified({ coord, id: spot.id });
|
||||
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
||||
const reporters = status?.reporterCount ?? 0;
|
||||
const reports = getReportCounts(spot.id);
|
||||
const flags = spotFlags(spot, {
|
||||
mapperTrust: trustScores?.get(spot.pubkey),
|
||||
communityStatusReports: reporters,
|
||||
communityPhotos: photos,
|
||||
});
|
||||
// Human reports outrank heuristics: someone looked and objected.
|
||||
if (reports.notASpot > 0) flags.unshift('reported: not a skate spot');
|
||||
else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`);
|
||||
return {
|
||||
spot,
|
||||
coord,
|
||||
|
|
@ -90,19 +102,20 @@ export default function AdminReviewPage() {
|
|||
trust: trustScores?.get(spot.pubkey),
|
||||
reporters,
|
||||
photos,
|
||||
flags: spotFlags(spot, {
|
||||
mapperTrust: trustScores?.get(spot.pubkey),
|
||||
communityStatusReports: reporters,
|
||||
communityPhotos: photos,
|
||||
}),
|
||||
reports,
|
||||
flags,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.actioned || row.flags.length > 0)
|
||||
// Un-actioned rows first (most flags first); actioned rows sink below.
|
||||
.sort((a, b) =>
|
||||
a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1,
|
||||
),
|
||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount],
|
||||
// Un-actioned first; within that, human reports before heuristics;
|
||||
// actioned rows sink to the bottom (they are done, but undoable).
|
||||
.sort((a, b) => {
|
||||
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
|
||||
if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot;
|
||||
if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total;
|
||||
return b.flags.length - a.flags.length;
|
||||
}),
|
||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts],
|
||||
);
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
|
@ -230,7 +243,11 @@ export default function AdminReviewPage() {
|
|||
{!hidden && !verified && 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"
|
||||
className={
|
||||
f.startsWith('reported')
|
||||
? 'border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
|
||||
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
|
||||
}
|
||||
>
|
||||
{f}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ngeohash from 'ngeohash';
|
||||
|
|
@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label';
|
|||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { calculateDistance } from '@/lib/sortOptions';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
|
|
@ -59,6 +62,12 @@ export default function CreateSpotPage() {
|
|||
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
|
||||
const [contactInfo, setContactInfo] = useState('');
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
// Duplicate guard: warn when a spot already exists right where you're pinning
|
||||
// one. This is the cheapest fix for clone spam (five near-identical pins for
|
||||
// the same spot) — and it points the contributor at the existing pin instead.
|
||||
const { data: existingSpots } = useSkateSpots(500);
|
||||
const [dupWarning, setDupWarning] = useState<Array<{ spot: SkateSpot; km: number }> | null>(null);
|
||||
const dupConfirmedRef = useRef(false);
|
||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
const [showCamera, setShowCamera] = useState(false);
|
||||
// True while the Quick Snap photo is uploading in the background after landing
|
||||
|
|
@ -209,8 +218,8 @@ export default function CreateSpotPage() {
|
|||
setImages((prev) => prev.filter((img) => img !== url));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
toast({
|
||||
|
|
@ -275,6 +284,29 @@ export default function CreateSpotPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Look for an existing spot within 60 m and ask before creating a clone.
|
||||
if (!dupConfirmedRef.current) {
|
||||
const nearby = (existingSpots ?? [])
|
||||
.map((s) => {
|
||||
if (!s.geohash) return null;
|
||||
const { latitude, longitude } = ngeohash.decode(s.geohash);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return { spot: s, km: calculateDistance(lat, lng, latitude, longitude) };
|
||||
})
|
||||
.filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06)
|
||||
.sort((a, b) => a.km - b.km)
|
||||
.slice(0, 3);
|
||||
|
||||
if (nearby.length > 0) {
|
||||
setDupWarning(nearby);
|
||||
toast({
|
||||
title: 'That spot may already be on the map',
|
||||
description: 'Check the pin below before adding a duplicate.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate geohash from coordinates (7 chars = ~153m precision)
|
||||
const geohash = ngeohash.encode(lat, lng, 7);
|
||||
|
|
@ -765,6 +797,53 @@ export default function CreateSpotPage() {
|
|||
hasSpotType={!!selectedCategory}
|
||||
/>
|
||||
|
||||
{dupWarning && (
|
||||
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||
This spot may already exist
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-300">
|
||||
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of
|
||||
your pin. If it's the same place, add your photo or a status report there
|
||||
instead of creating a duplicate.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{dupWarning.map(({ spot, km }) => (
|
||||
<li key={spot.id} className="truncate text-xs">
|
||||
<Link
|
||||
to={`/spot/${spot.pubkey}/${spot.identifier}`}
|
||||
className="text-[#ccff00] hover:opacity-80"
|
||||
>
|
||||
{spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-white/20"
|
||||
onClick={() => {
|
||||
dupConfirmedRef.current = true;
|
||||
setDupWarning(null);
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
It's a different spot — publish anyway
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-gray-400"
|
||||
onClick={() => setDupWarning(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import {
|
|||
ShieldCheck,
|
||||
ShieldQuestion,
|
||||
BadgeCheck,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
|
|
@ -178,6 +179,10 @@ export default function SpotDetailPage() {
|
|||
// Staff verdict (NIP-32 `verified` label) — separate from the community
|
||||
// signal: a human actually looked at this one.
|
||||
const moderation = useAdminModeration();
|
||||
const staffHidden = !!spot && moderation.has({
|
||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||
id: spot.id,
|
||||
});
|
||||
const staffVerified = !!spot && moderation.isVerified({
|
||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||
id: spot.id,
|
||||
|
|
@ -547,6 +552,38 @@ export default function SpotDetailPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Author-facing moderation notice. The verdicts are public
|
||||
labels, so there is nothing secret to hide — and without this
|
||||
a contributor whose spot was hidden just sees it vanish. */}
|
||||
{isOwner && staffHidden && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||
Hidden by a moderator
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||
This spot is not shown on the map or in search. You can still see it because
|
||||
it's yours. Editing it will not un-hide it — it stays hidden until a moderator
|
||||
retracts the verdict.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isOwner && staffVerified && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/5 p-4">
|
||||
<BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||
Verified by SkateSpots
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||
A moderator reviewed this spot and vouched for it. Nice one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Community Contributions Section */}
|
||||
{!isShop && (
|
||||
<Card className="bg-black/90 border-2 border-blue-500/30 overflow-hidden">
|
||||
|
|
|
|||
Loading…
Reference in a new issue