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:
parent
efd7e9608d
commit
f791e5375c
1 changed files with 87 additions and 59 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue