diff --git a/AGENTS.md b/AGENTS.md
index d74bb3c..aaa7947 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1098,11 +1098,31 @@ session with **no other memory of this project**. So:
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
-- **Photo is recommended, not required (2026-09-28):** the hard block in
- `CreateSpotPage` was removed — it punished an honest mapper with no photo on
- hand. Publishing with no photo is allowed; the spot stays **Unverified**
- (`SpotDetailPage` badge), the success toast says so, and the review queue's
- 'no photo' flag lets a human look later. The pre-submit checklist still nudges.
+- **Photo + description are required again (2026-10-07, reverses 2026-09-28).**
+ Owner decision: the create-spot form now requires **≥1 photo AND a non-empty
+ description** (on top of name/type/location) — the empty husk / one-word test
+ pin can't publish. ⚠️ This is a **junk filter, not sybil defence**: a bot can
+ upload a random image; the real sybil levers remain PoW, WoT weighting and the
+ ≥3-distinct-reporter auto-hide. The 2026-09-28 "photo recommended, not required"
+ decision is **superseded** — do not revert. No-photo publishing is gone.
+- **Add-spot flow overhaul (2026-10-07).** Three parts, one commit:
+ (1) **Map tap no longer jumps** you into the form. `MapPage.handleMapClick`
+ drops a `pendingAddPin` (rendered by `SpotMap`'s `pendingPin` prop) and shows an
+ "Add a spot here?" card; only its **Add spot** button navigates to
+ `/create-spot?lat=&lng=`. (2) **The form has a real pin map** —
+ `src/components/SpotLocationPicker.tsx` (tap to drop, drag to fine-tune, cached
+ tiles). `createSpot.pickOnMap` was an **orphaned key** — the map picker had been
+ intended all along; raw lat/lng now live behind an "Enter coordinates manually"
+ toggle. (3) **Progressive disclosure + inline errors + draft autosave:** required
+ fields (map, name, description, type, photo) are up front; access/contact/shelter/
+ tags/address sit under a **More details** collapsible (access defaults public,
+ shelter defaults outdoor); field errors render inline (no toast per field); and
+ the form autosaves via **`useFormDraft`** (generalized from `useLabDraft`, same
+ resume-banner pattern). ⚠️ Leaflet's base CSS is **CDN-loaded in `index.html`**
+ (`src/main.tsx` just has a comment) — a new Leaflet map needs no CSS import.
+ Still open: the 60 m duplicate check still runs on submit with
+ `useSkateSpots(500)` (move it to pin-time + a targeted geohash query), and
+ publish still blocks on `reverseGeocodeRegions` before signing.
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
whenever the parent handed it fresh arrays, and added each marker with a
diff --git a/src/components/SpotLocationPicker.tsx b/src/components/SpotLocationPicker.tsx
new file mode 100644
index 0000000..93aac30
--- /dev/null
+++ b/src/components/SpotLocationPicker.tsx
@@ -0,0 +1,132 @@
+import { useEffect, useRef } from 'react';
+import L from 'leaflet';
+
+import { createCachedTileLayer } from '@/lib/cachedTileLayer';
+import { cn } from '@/lib/utils';
+
+export interface LatLng {
+ lat: number;
+ lng: number;
+}
+
+interface SpotLocationPickerProps {
+ value: LatLng | null;
+ onChange: (coords: LatLng) => void;
+ className?: string;
+}
+
+/** Fallback center before the user has a location (NYC, same as the map default). */
+const DEFAULT_CENTER: [number, number] = [40.7128, -74.006];
+
+/**
+ * A small, tappable Leaflet map for choosing a spot's location.
+ *
+ * Tap anywhere to drop the pin, or drag the pin to fine-tune. This replaces the
+ * raw latitude/longitude text boxes as the PRIMARY way to set a location — those
+ * move behind an "enter coordinates manually" toggle for power users. Uses the
+ * same cached tile layer as the main map, so the basemap still draws offline.
+ */
+export function SpotLocationPicker({ value, onChange, className }: SpotLocationPickerProps) {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+ const markerRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ // Captured once so the init effect doesn't depend on `value` (which changes
+ // constantly) — the marker-sync effect below handles later updates.
+ const initialValueRef = useRef(value);
+
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ }, [onChange]);
+
+ // Create the map once.
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el || mapRef.current) return;
+
+ const initial = initialValueRef.current;
+ const center: [number, number] = initial ? [initial.lat, initial.lng] : DEFAULT_CENTER;
+ const map = L.map(el, {
+ center,
+ zoom: initial ? 15 : 12,
+ zoomControl: true,
+ attributionControl: true,
+ });
+
+ createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '',
+ subdomains: 'abc',
+ minZoom: 2,
+ maxZoom: 19,
+ className: 'skatespots-dark-tiles',
+ }).addTo(map);
+
+ map.on('click', (e: L.LeafletMouseEvent) => {
+ onChangeRef.current({ lat: e.latlng.lat, lng: e.latlng.lng });
+ });
+
+ mapRef.current = map;
+
+ const ro = new ResizeObserver(() => map.invalidateSize());
+ ro.observe(el);
+ // Leaflet can mis-measure inside a freshly-laid-out page — nudge it once.
+ const t = window.setTimeout(() => map.invalidateSize(), 0);
+
+ return () => {
+ window.clearTimeout(t);
+ ro.disconnect();
+ map.remove();
+ mapRef.current = null;
+ markerRef.current = null;
+ };
+ }, []);
+
+ // Draw / move the draggable pin to match `value`.
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map) return;
+
+ if (!value) {
+ if (markerRef.current) {
+ markerRef.current.remove();
+ markerRef.current = null;
+ }
+ return;
+ }
+
+ const latlng: [number, number] = [value.lat, value.lng];
+ if (markerRef.current) {
+ markerRef.current.setLatLng(latlng);
+ return;
+ }
+
+ const marker = L.marker(latlng, {
+ draggable: true,
+ icon: L.divIcon({
+ className: '',
+ html:
+ '
' +
+ '' +
+ '' +
+ '
',
+ iconSize: [22, 34],
+ iconAnchor: [11, 34],
+ }),
+ }).addTo(map);
+
+ marker.on('dragend', () => {
+ const p = marker.getLatLng();
+ onChangeRef.current({ lat: p.lat, lng: p.lng });
+ });
+
+ markerRef.current = marker;
+ map.setView(latlng, Math.max(map.getZoom(), 15), { animate: true });
+ }, [value]);
+
+ return (
+
+ );
+}
diff --git a/src/hooks/useFormDraft.ts b/src/hooks/useFormDraft.ts
new file mode 100644
index 0000000..682ba22
--- /dev/null
+++ b/src/hooks/useFormDraft.ts
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState } from 'react';
+
+/**
+ * Autosave a long form so it isn't lost on navigation or reload.
+ *
+ * - Persists `snapshot` to localStorage under `` (content-debounced:
+ * the serialized string only changes when the draft actually changes).
+ * - A saved draft is only offered as **resumable** when it *differs* from the
+ * current form state — so merely opening an existing record (whose prefill
+ * matches the saved draft) never shows a spurious "resume" banner, and a
+ * draft can never silently clobber a freshly loaded record.
+ * - Call `clear()` after a successful publish.
+ *
+ * Used by the Lab profile forms (`useLabDraft`) and the create-spot form.
+ */
+export function useFormDraft(
+ prefix: string,
+ key: string,
+ snapshot: T,
+ apply: (draft: T) => void,
+) {
+ const storageKey = prefix + key;
+ const [loaded, setLoaded] = useState(null);
+ const firstRun = useRef(true);
+ const serialized = JSON.stringify(snapshot);
+
+ // Load any saved draft once.
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (raw) setLoaded(JSON.parse(raw) as T);
+ } catch {
+ // ignore malformed/blocked storage
+ }
+ }, [storageKey]);
+
+ // Persist on change (skip the initial empty paint).
+ useEffect(() => {
+ if (firstRun.current) {
+ firstRun.current = false;
+ return;
+ }
+ try {
+ localStorage.setItem(storageKey, serialized);
+ } catch {
+ // storage may be full/blocked — a lost draft is not fatal
+ }
+ }, [serialized, storageKey]);
+
+ const resume = () => {
+ if (!loaded) return;
+ apply(loaded);
+ setLoaded(null);
+ };
+
+ const discard = () => {
+ setLoaded(null);
+ try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
+ };
+
+ const clear = () => {
+ try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
+ };
+
+ const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
+ return { resumable, resume, discard, clear };
+}
diff --git a/src/hooks/useLabDraft.ts b/src/hooks/useLabDraft.ts
index 28d99d1..8227eb5 100644
--- a/src/hooks/useLabDraft.ts
+++ b/src/hooks/useLabDraft.ts
@@ -1,62 +1,11 @@
-import { useEffect, useRef, useState } from 'react';
+import { useFormDraft } from './useFormDraft';
const PREFIX = 'skatespots:lab-draft:';
/**
- * Autosave a Lab profile form so a long create/edit isn't lost on nav or reload.
- *
- * - Persists `snapshot` to localStorage (content-debounced: the serialized
- * string only changes when the draft actually changes).
- * - A saved draft is only offered as **resumable** when it *differs* from the
- * current form state — so merely opening an existing profile (whose prefill
- * matches the saved draft) never shows a spurious "resume" banner, and a
- * draft can never silently clobber a freshly loaded profile.
- * - Call `clear()` after a successful publish.
+ * Autosave a Lab profile form. Thin wrapper over the shared `useFormDraft` —
+ * kept as a named export so the existing Lab call sites don't change.
*/
export function useLabDraft(key: string, snapshot: T, apply: (draft: T) => void) {
- const storageKey = PREFIX + key;
- const [loaded, setLoaded] = useState(null);
- const firstRun = useRef(true);
- const serialized = JSON.stringify(snapshot);
-
- // Load any saved draft once.
- useEffect(() => {
- try {
- const raw = localStorage.getItem(storageKey);
- if (raw) setLoaded(JSON.parse(raw) as T);
- } catch {
- // ignore malformed/blocked storage
- }
- }, [storageKey]);
-
- // Persist on change (skip the initial empty paint).
- useEffect(() => {
- if (firstRun.current) {
- firstRun.current = false;
- return;
- }
- try {
- localStorage.setItem(storageKey, serialized);
- } catch {
- // storage may be full/blocked — a lost draft is not fatal
- }
- }, [serialized, storageKey]);
-
- const resume = () => {
- if (!loaded) return;
- apply(loaded);
- setLoaded(null);
- };
-
- const discard = () => {
- setLoaded(null);
- try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
- };
-
- const clear = () => {
- try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
- };
-
- const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
- return { resumable, resume, discard, clear };
+ return useFormDraft(PREFIX, key, snapshot, apply);
}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 6ab2786..699918f 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -502,6 +502,18 @@ nav: {
useCurrentLocation: 'Use my current location',
pickOnMap: 'Pick Location on Map',
loginToAdd: 'You need to log in to add a skate spot.',
+ locationLabel: 'Location',
+ tapToPlace: 'Tap the map to place the pin, or drag it to fine-tune.',
+ manualCoords: 'Enter coordinates manually',
+ moreDetails: 'More details (optional)',
+ photoHint: 'a photo is required to publish',
+ nameRequired: 'Please enter a name for this spot.',
+ descriptionRequired: 'Please describe this spot.',
+ photoRequired: 'Please add at least one photo.',
+ draftTitle: 'You have an unsaved draft',
+ draftBody: 'Pick up where you left off, or discard it.',
+ draftResume: 'Resume',
+ draftDiscard: 'Discard draft',
requiredFields: 'Tell us about this spot. All fields marked with * are required.',
coordinates: 'Coordinates',
latitudePlaceholder: 'Latitude (e.g., 34.1012)',
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 60abdb7..327cf80 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -502,6 +502,18 @@ nav: {
useCurrentLocation: 'Usar mi ubicación actual',
pickOnMap: 'Elegir Ubicación en el Mapa',
loginToAdd: 'Necesitas iniciar sesión para añadir un spot de skate.',
+ locationLabel: 'Ubicación',
+ tapToPlace: 'Toca el mapa para colocar el pin, o arrástralo para ajustarlo.',
+ manualCoords: 'Introducir coordenadas manualmente',
+ moreDetails: 'Más detalles (opcional)',
+ photoHint: 'se requiere una foto para publicar',
+ nameRequired: 'Ponle un nombre a este spot.',
+ descriptionRequired: 'Describe este spot.',
+ photoRequired: 'Añade al menos una foto.',
+ draftTitle: 'Tienes un borrador sin guardar',
+ draftBody: 'Continúa donde lo dejaste o descártalo.',
+ draftResume: 'Continuar',
+ draftDiscard: 'Descartar borrador',
requiredFields: 'Cuéntanos sobre este spot. Los campos con * son obligatorios.',
coordinates: 'Coordenadas',
latitudePlaceholder: 'Latitud (ej., 34.1012)',
diff --git a/src/i18n/th/batch5.ts b/src/i18n/th/batch5.ts
index e1f22c4..4c5f5d7 100644
--- a/src/i18n/th/batch5.ts
+++ b/src/i18n/th/batch5.ts
@@ -359,6 +359,22 @@ const thBatch5 = {
publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่',
unknownError: 'ข้อผิดพลาดไม่ทราบสาเหตุ',
},
+ // Partial: only the keys this form added — the rest of createSpot still
+ // falls back to English until a full translation batch lands.
+ createSpot: {
+ locationLabel: 'ตำแหน่ง',
+ tapToPlace: 'แตะแผนที่เพื่อปักหมุด หรือลากหมุดเพื่อปรับให้ละเอียด',
+ manualCoords: 'กรอกพิกัดเอง',
+ moreDetails: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
+ photoHint: 'ต้องมีรูปจึงจะเผยแพร่ได้',
+ nameRequired: 'กรุณาตั้งชื่อสปอตนี้',
+ descriptionRequired: 'กรุณาอธิบายสปอตนี้',
+ photoRequired: 'กรุณาเพิ่มรูปอย่างน้อยหนึ่งรูป',
+ draftTitle: 'คุณมีฉบับร่างที่ยังไม่ได้บันทึก',
+ draftBody: 'ทำต่อจากที่ค้างไว้ หรือลบทิ้ง',
+ draftResume: 'ทำต่อ',
+ draftDiscard: 'ลบฉบับร่าง',
+ },
};
export default thBatch5;
diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx
index 267aef4..9bbe2cd 100644
--- a/src/pages/CreateSpotPage.tsx
+++ b/src/pages/CreateSpotPage.tsx
@@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useSkateSpots } from '@/hooks/useSkateSpots';
+import { useFormDraft } from '@/hooks/useFormDraft';
import { calculateDistance } from '@/lib/sortOptions';
import type { SkateSpot } from '@/lib/types';
import { useToast } from '@/hooks/useToast';
@@ -19,6 +21,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { QualityMeter } from '@/components/QualityMeter';
import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput';
+import { SpotLocationPicker, type LatLng } from '@/components/SpotLocationPicker';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
@@ -31,15 +34,43 @@ import {
ArrowLeft,
Check,
Camera,
+ ChevronDown,
+ Crosshair,
+ History,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
import { Link } from 'react-router-dom';
+/** Per-field inline validation messages (no toast for field errors). */
+type FieldErrors = {
+ title?: string;
+ description?: string;
+ location?: string;
+ type?: string;
+ photo?: string;
+};
+/** Shape of the autosaved draft. Keep every field optional on read so a draft
+ * saved before a new field existed can never crash the form. */
+type SpotDraft = {
+ title: string;
+ description: string;
+ location: string;
+ latitude: string;
+ longitude: string;
+ selectedCategory: string;
+ tags: string[];
+ accessType: 'public' | 'private' | 'locals';
+ shelter: ShelterType;
+ contactInfo: string;
+ images: string[];
+};
+
+const DRAFT_PREFIX = 'skatespots:spot-draft:';
export default function CreateSpotPage() {
const { t } = useTranslation();
-
+
useSeoMeta({
title: `${t('createSpot.title')} - SkateSpots`,
description: t('createSpot.subtitle'),
@@ -64,6 +95,9 @@ export default function CreateSpotPage() {
const [shelter, setShelter] = useState(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState([]);
+ const [errors, setErrors] = useState({});
+ const [showMore, setShowMore] = useState(false);
+ const [showManualCoords, setShowManualCoords] = useState(false);
// 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.
@@ -76,15 +110,51 @@ export default function CreateSpotPage() {
// on this form.
const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false);
- // Pre-fill coordinates from URL params (from map click)
+ // Derived coordinates from the (possibly manually typed) strings.
+ const lat = parseFloat(latitude);
+ const lng = parseFloat(longitude);
+ const hasCoords =
+ Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
+ const coords: LatLng | null = hasCoords ? { lat, lng } : null;
+
+ const clearError = (field: keyof FieldErrors) =>
+ setErrors((prev) => (prev[field] ? { ...prev, [field]: undefined } : prev));
+
+ const handlePick = (c: LatLng) => {
+ setLatitude(c.lat.toFixed(6));
+ setLongitude(c.lng.toFixed(6));
+ clearError('location');
+ };
+
+ // Autosave the draft so a long form isn't lost on a back-navigation.
+ const draft = useFormDraft(
+ DRAFT_PREFIX,
+ 'new',
+ { title, description, location, latitude, longitude, selectedCategory, tags, accessType, shelter, contactInfo, images },
+ (d) => {
+ setTitle(d.title ?? '');
+ setDescription(d.description ?? '');
+ setLocation(d.location ?? '');
+ setLatitude(d.latitude ?? '');
+ setLongitude(d.longitude ?? '');
+ setSelectedCategory(d.selectedCategory ?? '');
+ setTags(Array.isArray(d.tags) ? d.tags : []);
+ setAccessType(d.accessType ?? 'public');
+ setShelter(d.shelter ?? DEFAULT_SHELTER);
+ setContactInfo(d.contactInfo ?? '');
+ setImages(Array.isArray(d.images) ? d.images : []);
+ },
+ );
+
+ // Pre-fill coordinates from URL params (from map tap / quick capture).
useEffect(() => {
- const lat = searchParams.get('lat');
- const lng = searchParams.get('lng');
- if (lat && lng) {
- setLatitude(lat);
- setLongitude(lng);
+ const latParam = searchParams.get('lat');
+ const lngParam = searchParams.get('lng');
+ if (latParam && lngParam) {
+ setLatitude(latParam);
+ setLongitude(lngParam);
}
-
+
// Check for quick capture image from map page
const quickCapture = searchParams.get('quickCapture');
if (quickCapture === 'true') {
@@ -130,6 +200,7 @@ export default function CreateSpotPage() {
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
+ clearError('type');
};
const getCurrentLocation = () => {
@@ -148,6 +219,7 @@ export default function CreateSpotPage() {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
+ clearError('location');
toast({
title: t('spotUi.locationFound'),
description: t('spotUi.locationFoundDesc'),
@@ -179,6 +251,7 @@ export default function CreateSpotPage() {
}
}
if (added > 0) {
+ clearError('photo');
toast({
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
description:
@@ -202,6 +275,7 @@ export default function CreateSpotPage() {
if (url) {
setImages((prev) => [...prev, url]);
setShowCamera(false);
+ clearError('photo');
toast({
title: t('spotUi.photoCaptured'),
description: t('spotUi.photoCapturedDesc'),
@@ -223,44 +297,21 @@ export default function CreateSpotPage() {
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
- if (!title.trim()) {
- toast({
- title: t('spotUi.titleRequired'),
- description: t('spotUi.titleRequiredDesc'),
- variant: 'destructive',
- });
- return;
- }
-
- if (!latitude || !longitude) {
- toast({
- title: t('spotUi.locationRequired'),
- description: t('spotUi.locationRequiredDesc'),
- variant: 'destructive',
- });
- return;
- }
-
- const lat = parseFloat(latitude);
- const lng = parseFloat(longitude);
-
- if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
- toast({
- title: t('spotUi.invalidCoordinates'),
- description: t('spotUi.invalidCoordinatesDesc'),
- variant: 'destructive',
- });
- return;
- }
-
- if (!selectedCategory) {
- toast({
- title: t('spotUi.spotTypeRequired'),
- description: t('spotUi.spotTypeRequiredDesc'),
- variant: 'destructive',
- });
+ // Inline validation — collect every missing required field at once instead
+ // of firing one toast at a time. Photo + description are mandatory: they are
+ // the cheapest defence against test pins and empty husks.
+ const nextErrors: FieldErrors = {};
+ if (!title.trim()) nextErrors.title = t('createSpot.nameRequired');
+ if (!description.trim()) nextErrors.description = t('createSpot.descriptionRequired');
+ if (!hasCoords) nextErrors.location = t('spotUi.locationRequiredDesc');
+ if (!selectedCategory) nextErrors.type = t('spotUi.spotTypeRequiredDesc');
+ if (images.length === 0) nextErrors.photo = t('createSpot.photoRequired');
+
+ if (Object.keys(nextErrors).length > 0) {
+ setErrors(nextErrors);
return;
}
+ setErrors({});
// Don't publish while the Quick Snap photo is still uploading — otherwise the
// spot would go out without the photo the user just took.
@@ -272,19 +323,6 @@ export default function CreateSpotPage() {
return;
}
- // A photo is still NOT required — but a spot needs *something* beyond a
- // title + type. Require a photo OR a description so the empty husk (a "test"
- // pin, an airport with one word) can't publish, without blocking an honest
- // mapper who has no picture on hand but can describe the place.
- if (images.length === 0 && !description.trim()) {
- toast({
- title: t('spotUi.addPhotoOrDescription'),
- description: t('spotUi.addPhotoOrDescriptionDesc'),
- variant: 'destructive',
- });
- return;
- }
-
// Look for an existing spot within 60 m and ask before creating a clone.
if (!dupConfirmedRef.current) {
const nearby = (existingSpots ?? [])
@@ -368,7 +406,7 @@ export default function CreateSpotPage() {
// Also add as t tag for backwards compatibility and relay filtering
eventTags.push(['t', selectedCategory]);
}
-
+
// Add all freeform tags
for (const tag of tags) {
eventTags.push(['t', tag]);
@@ -379,7 +417,7 @@ export default function CreateSpotPage() {
// Use imeta tag for NIP-92 compatibility
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
}
-
+
// Add alt tag for NIP-31 (human-readable description)
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
@@ -389,11 +427,11 @@ export default function CreateSpotPage() {
tags: eventTags,
});
+ draft.clear();
+
toast({
title: t('spotUi.spotAdded'),
- description: images.length === 0
- ? t('spotUi.spotAddedNoPhoto')
- : t('spotUi.spotAddedDesc'),
+ description: t('spotUi.spotAddedDesc'),
});
// Navigate to spot detail using pubkey and identifier
@@ -443,6 +481,32 @@ export default function CreateSpotPage() {