feat(create-spot): warn about nearby duplicates at pin-time; share the map's spot cache (2000) instead of a second query

This commit is contained in:
SkateSpots 2026-10-07 10:30:19 +07:00
parent efd7e9608d
commit f791e5375c

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -101,7 +101,7 @@ export default function CreateSpotPage() {
// 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 { data: existingSpots } = useSkateSpots(2000);
const [dupWarning, setDupWarning] = useState<Array<{ spot: SkateSpot; km: number }> | null>(null);
const dupConfirmedRef = useRef(false);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -120,6 +120,42 @@ export default function CreateSpotPage() {
const clearError = (field: keyof FieldErrors) =>
setErrors((prev) => (prev[field] ? { ...prev, [field]: undefined } : prev));
// Spots within 60 m of a point — used both for the live warning (shown as soon
// as the pin lands) and the final guard on submit.
const findNearby = useCallback(
(la: number, ln: number) =>
(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(la, ln, 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),
[existingSpots],
);
// Warn as soon as the pin lands (debounced), not only when you hit submit.
useEffect(() => {
if (!hasCoords) {
setDupWarning(null);
return;
}
const timer = window.setTimeout(() => {
if (dupConfirmedRef.current) return;
const near = findNearby(lat, lng);
setDupWarning(near.length > 0 ? near : null);
}, 400);
return () => window.clearTimeout(timer);
}, [hasCoords, lat, lng, findNearby]);
// Moving the pin re-arms the "publish anyway" confirmation.
useEffect(() => {
dupConfirmedRef.current = false;
}, [lat, lng]);
const handlePick = (c: LatLng) => {
setLatitude(c.lat.toFixed(6));
setLongitude(c.lng.toFixed(6));
@ -325,16 +361,7 @@ export default function CreateSpotPage() {
// 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);
const nearby = findNearby(lat, lng);
if (nearby.length > 0) {
setDupWarning(nearby);
@ -575,6 +602,54 @@ export default function CreateSpotPage() {
</Collapsible>
</div>
{/* Duplicate warning — appears as soon as the pin lands, right
below the map, instead of only on submit. */}
{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">
{t('spotUi.spotMayAlreadyExist')}
</p>
<p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1
? t('spotUi.duplicateOne')
: t('spotUi.duplicateOther')}
</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 || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })}
</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);
}}
>
{t('spotUi.differentSpotPublishAnyway')}
</Button>
<Button
type="button"
variant="ghost"
className="text-gray-400"
onClick={() => setDupWarning(null)}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{/* ── Title ─────────────────────────────────────────────── */}
<div className="space-y-2">
<Label htmlFor="title">{t('spots.spotName')} *</Label>
@ -882,53 +957,6 @@ 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">
{t('spotUi.spotMayAlreadyExist')}
</p>
<p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1
? t('spotUi.duplicateOne')
: t('spotUi.duplicateOther')}
</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 || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })}
</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();
}}
>
{t('spotUi.differentSpotPublishAnyway')}
</Button>
<Button
type="button"
variant="ghost"
className="text-gray-400"
onClick={() => setDupWarning(null)}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{/* Submit */}
<Button
type="submit"