feat(create-spot): pin map, progressive disclosure, mandatory photo+description, inline errors, draft autosave
This commit is contained in:
parent
24322b3b1c
commit
efd7e9608d
8 changed files with 650 additions and 341 deletions
30
AGENTS.md
30
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
|
||||
|
|
|
|||
132
src/components/SpotLocationPicker.tsx
Normal file
132
src/components/SpotLocationPicker.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markerRef = useRef<L.Marker | null>(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:
|
||||
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
|
||||
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
|
||||
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
|
||||
'</div>',
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('h-64 w-full overflow-hidden rounded-xl border-2 border-white/10', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
src/hooks/useFormDraft.ts
Normal file
67
src/hooks/useFormDraft.ts
Normal file
|
|
@ -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 `<prefix><key>` (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<T>(
|
||||
prefix: string,
|
||||
key: string,
|
||||
snapshot: T,
|
||||
apply: (draft: T) => void,
|
||||
) {
|
||||
const storageKey = prefix + key;
|
||||
const [loaded, setLoaded] = useState<T | null>(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 };
|
||||
}
|
||||
|
|
@ -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<T>(key: string, snapshot: T, apply: (draft: T) => void) {
|
||||
const storageKey = PREFIX + key;
|
||||
const [loaded, setLoaded] = useState<T | null>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ShelterType>(DEFAULT_SHELTER);
|
||||
const [contactInfo, setContactInfo] = useState('');
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
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<SpotDraft>(
|
||||
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() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unsaved-draft banner */}
|
||||
{draft.resumable && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3">
|
||||
<History className="h-5 w-5 flex-shrink-0 text-[#ccff00]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">{t('createSpot.draftTitle')}</p>
|
||||
<p className="text-xs text-gray-400">{t('createSpot.draftBody')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={draft.resume}
|
||||
className="min-h-0 flex-shrink-0 rounded-lg bg-[#ccff00] px-3 py-1.5 text-xs font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{t('createSpot.draftResume')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={draft.discard}
|
||||
aria-label={t('createSpot.draftDiscard')}
|
||||
className="min-h-0 min-w-0 flex-shrink-0 rounded-full p-1 text-gray-500 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
||||
<CardHeader>
|
||||
|
|
@ -452,188 +516,93 @@ export default function CreateSpotPage() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Title */}
|
||||
{/* ── Location (map picker) ─────────────────────────────── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-bold">{t('createSpot.locationLabel')} *</Label>
|
||||
<SpotLocationPicker value={coords} onChange={handlePick} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={getCurrentLocation}
|
||||
disabled={isGettingLocation}
|
||||
>
|
||||
{isGettingLocation ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('createSpot.gettingLocation')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Crosshair className="h-4 w-4 mr-2" />
|
||||
{t('createSpot.useCurrentLocation')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">{t('createSpot.tapToPlace')}</span>
|
||||
</div>
|
||||
{errors.location && (
|
||||
<p className="text-xs font-medium text-red-400">{errors.location}</p>
|
||||
)}
|
||||
|
||||
<Collapsible open={showManualCoords} onOpenChange={setShowManualCoords}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs font-bold text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showManualCoords && 'rotate-180')} />
|
||||
{t('createSpot.manualCoords')}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
placeholder={t('createSpot.latitudePlaceholder')}
|
||||
value={latitude}
|
||||
onChange={(e) => { setLatitude(e.target.value); clearError('location'); }}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('createSpot.longitudePlaceholder')}
|
||||
value={longitude}
|
||||
onChange={(e) => { setLongitude(e.target.value); clearError('location'); }}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* ── Title ─────────────────────────────────────────────── */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{t('spots.spotName')} *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={t('createSpot.namePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
onChange={(e) => { setTitle(e.target.value); clearError('title'); }}
|
||||
className={cn(errors.title && 'border-red-500/60')}
|
||||
/>
|
||||
{errors.title && <p className="text-xs font-medium text-red-400">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{/* ── Description ───────────────────────────────────────── */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{t('spots.description')}</Label>
|
||||
<Label htmlFor="description">{t('spots.description')} *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t('createSpot.descriptionPlaceholder')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={(e) => { setDescription(e.target.value); clearError('description'); }}
|
||||
rows={4}
|
||||
className={cn(errors.description && 'border-red-500/60')}
|
||||
/>
|
||||
{errors.description && <p className="text-xs font-medium text-red-400">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Location Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">{t('spots.address')}</Label>
|
||||
<Input
|
||||
id="location"
|
||||
placeholder={t('createSpot.addressPlaceholder')}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coordinates */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('createSpot.coordinates')} *</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
placeholder={t('createSpot.latitudePlaceholder')}
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={t('createSpot.longitudePlaceholder')}
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={getCurrentLocation}
|
||||
disabled={isGettingLocation}
|
||||
className="mt-2"
|
||||
>
|
||||
{isGettingLocation ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('createSpot.gettingLocation')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
{t('createSpot.useCurrentLocation')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Access Type */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
|
||||
<div className="space-y-2">
|
||||
{/* Public */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'public'
|
||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('public')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🌍</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'public' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Private */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'private'
|
||||
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
|
||||
: "border-gray-700 hover:border-[#ff6600]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('private')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'private' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ask Locals */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'locals'
|
||||
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
|
||||
: "border-gray-700 hover:border-[#ccff00]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('locals')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">👥</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'locals' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
|
||||
<span className="text-black text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info for Private/Locals */}
|
||||
{(accessType === 'private' || accessType === 'locals') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder={t('createSpot.contactPlaceholder')}
|
||||
value={contactInfo}
|
||||
onChange={(e) => setContactInfo(e.target.value)}
|
||||
className="bg-black border-[#ff6600]/30"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
{accessType === 'private'
|
||||
? t('createSpot.contactHintPrivate')
|
||||
: t('createSpot.contactHintLocals')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spot Category (required, mutually exclusive) */}
|
||||
{/* ── Spot type ─────────────────────────────────────────── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -660,65 +629,15 @@ export default function CreateSpotPage() {
|
|||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{errors.type && <p className="text-xs font-medium text-red-400">{errors.type}</p>}
|
||||
</div>
|
||||
|
||||
{/* Shelter / weather protection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
{t('spotUi.shelterHint')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shelterOptions.map((option) => (
|
||||
<Card
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
|
||||
shelter === option.value
|
||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||
)}
|
||||
onClick={() => setShelter(option.value)}
|
||||
>
|
||||
<CardContent className="p-3 sm:p-4 relative">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{shelter === option.value && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freeform Tags */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
||||
</p>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder={t('spotUi.typeToSearchTags')}
|
||||
maxTags={MAX_TAGS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Images — required (see the guard in handleSubmit). */}
|
||||
{/* ── Photos (required) ─────────────────────────────────── */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t('spots.photos')}
|
||||
{t('spots.photos')} *
|
||||
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
||||
{t('spotUi.photosRecommended')}
|
||||
{t('createSpot.photoHint')}
|
||||
</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
|
@ -760,6 +679,7 @@ export default function CreateSpotPage() {
|
|||
className="aspect-video"
|
||||
/>
|
||||
</div>
|
||||
{errors.photo && <p className="text-xs font-medium text-red-400">{errors.photo}</p>}
|
||||
</div>
|
||||
|
||||
{/* Camera Capture Dialog */}
|
||||
|
|
@ -770,10 +690,191 @@ export default function CreateSpotPage() {
|
|||
isUploading={isUploading}
|
||||
/>
|
||||
|
||||
{/* ── More details (optional) ───────────────────────────── */}
|
||||
<Collapsible open={showMore} onOpenChange={setShowMore} className="rounded-xl border border-white/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 p-4 text-left"
|
||||
>
|
||||
<span className="text-sm font-black uppercase tracking-wide text-white">
|
||||
{t('createSpot.moreDetails')}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 text-gray-400 transition-transform', showMore && 'rotate-180')} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-6 px-4 pb-4">
|
||||
{/* Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">{t('spots.address')}</Label>
|
||||
<Input
|
||||
id="location"
|
||||
placeholder={t('createSpot.addressPlaceholder')}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access Type */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('createSpot.accessType')}</Label>
|
||||
<div className="space-y-2">
|
||||
{/* Public */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'public'
|
||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('public')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🌍</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'public' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Private */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'private'
|
||||
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
|
||||
: "border-gray-700 hover:border-[#ff6600]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('private')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'private' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ask Locals */}
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2",
|
||||
accessType === 'locals'
|
||||
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
|
||||
: "border-gray-700 hover:border-[#ccff00]/50"
|
||||
)}
|
||||
onClick={() => setAccessType('locals')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">👥</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'locals' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
|
||||
<span className="text-black text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info for Private/Locals */}
|
||||
{(accessType === 'private' || accessType === 'locals') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder={t('createSpot.contactPlaceholder')}
|
||||
value={contactInfo}
|
||||
onChange={(e) => setContactInfo(e.target.value)}
|
||||
className="bg-black border-[#ff6600]/30"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
{accessType === 'private'
|
||||
? t('createSpot.contactHintPrivate')
|
||||
: t('createSpot.contactHintLocals')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shelter / weather protection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
{t('spotUi.shelterHint')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shelterOptions.map((option) => (
|
||||
<Card
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
|
||||
shelter === option.value
|
||||
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
|
||||
: "border-gray-700 hover:border-[#ff0040]/50"
|
||||
)}
|
||||
onClick={() => setShelter(option.value)}
|
||||
>
|
||||
<CardContent className="p-3 sm:p-4 relative">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{shelter === option.value && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freeform Tags */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
||||
</p>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder={t('spotUi.typeToSearchTags')}
|
||||
maxTags={MAX_TAGS}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Quality Meter */}
|
||||
<QualityMeter
|
||||
type="spot"
|
||||
hasLocation={!!(latitude && longitude)}
|
||||
hasLocation={hasCoords}
|
||||
hasPhotos={images.length > 0}
|
||||
photoCount={images.length}
|
||||
hasDescription={description.trim().length > 0}
|
||||
|
|
|
|||
Loading…
Reference in a new issue