SkateSpots/src/pages/CreateSpotPage.tsx

988 lines
42 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { ToastAction } from '@/components/ui/toast';
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';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea';
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, type GeocodedRegions } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { SPOT_TAG_CATEGORY_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS, ACCESS_TYPE_KEYS } from '@/lib/labelKeys';
import { getCurrentPosition } from '@/lib/geolocation';
import { getLastLocation, getLastMapView, saveLastMapView } from '@/lib/lastLocation';
import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions';
import { cn } from '@/lib/utils';
import {
MapPin,
X,
Loader2,
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:';
/** How long to wait for a reverse-geocode before publishing without region tags. */
const GEOCODE_BUDGET_MS = 2500;
function mimeForImageUrl(url: string): string {
const match = url.match(/\.(png|webp|gif|heic|heif|jpe?g)(?:[?#]|$)/i);
if (!match) return 'image/jpeg';
const ext = match[1].toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
if (ext === 'heic' || ext === 'heif') return 'image/heic';
return `image/${ext}`;
}
function validCoords(latParam: string | null, lngParam: string | null): LatLng | null {
if (!latParam || !lngParam) return null;
const lat = parseFloat(latParam);
const lng = parseFloat(lngParam);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
return { lat, lng };
}
export default function CreateSpotPage() {
const { t } = useTranslation();
useSeoMeta({
title: `${t('createSpot.title')} - SkateSpots`,
description: t('createSpot.subtitle'),
});
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Start from the place the user was already looking at. A live GPS fix is
// deliberately NOT used as an automatic pin: the pin becomes public.
const initial = useMemo(() => {
const urlPin = validCoords(searchParams.get('lat'), searchParams.get('lng'));
if (urlPin) return { pin: urlPin, source: 'map' as const };
const savedView = getLastMapView();
if (savedView) {
return { pin: { lat: savedView.lat, lng: savedView.lon }, source: 'map' as const };
}
return { pin: null, source: null };
}, [searchParams]);
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState(initial.pin ? initial.pin.lat.toFixed(6) : '');
const [longitude, setLongitude] = useState(initial.pin ? initial.pin.lng.toFixed(6) : '');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
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 [showLocationMap, setShowLocationMap] = useState(!initial.pin);
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 and it points the contributor
// at the existing pin instead.
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);
const [showCamera, setShowCamera] = useState(false);
// 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));
// 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: spotLat, longitude: spotLng } = ngeohash.decode(s.geohash);
if (!Number.isFinite(spotLat) || !Number.isFinite(spotLng)) return null;
return { spot: s, km: calculateDistance(la, ln, spotLat, spotLng) };
})
.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));
clearError('location');
};
// Pre-fill coordinates from URL params (from map tap / quick capture).
useEffect(() => {
const pin = validCoords(searchParams.get('lat'), searchParams.get('lng'));
if (pin) {
setLatitude(pin.lat.toFixed(6));
setLongitude(pin.lng.toFixed(6));
setShowLocationMap(false);
clearError('location');
}
}, [searchParams]);
// Autosave the draft so a long form isn't lost on a back-navigation. Scope it
// to the account so switching accounts on a shared device can't reveal one
// person's unfinished spot to another.
const draft = useFormDraft<SpotDraft>(
DRAFT_PREFIX,
user?.pubkey ?? 'anon',
{ 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 : []);
if (d.latitude && d.longitude) setShowLocationMap(false);
},
);
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
clearError('type');
};
const getCurrentLocation = async () => {
setIsGettingLocation(true);
try {
const position = await getCurrentPosition();
setLatitude(position.lat.toFixed(6));
setLongitude(position.lng.toFixed(6));
setShowLocationMap(false);
clearError('location');
toast({
title: t('spotUi.locationFound'),
description: t('spotUi.locationFoundDesc'),
});
} catch (error) {
toast({
title: t('spotUi.locationError'),
description: error instanceof Error ? error.message : t('spotUi.locationRequiredDesc'),
variant: 'destructive',
});
} finally {
setIsGettingLocation(false);
}
};
// Upload one or more selected/dropped photos. Uploads run in sequence so we
// don't hammer Blossom with parallel requests.
const uploadImageFiles = async (files: File[]) => {
if (files.length === 0) return;
let added = 0;
try {
for (const file of files) {
const photoTags = await uploadFile(file);
const url = photoTags[0]?.[1];
if (url) {
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
added += 1;
}
}
if (added > 0) {
clearError('photo');
toast({
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
description: added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
});
}
} catch (error) {
toast({
title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
variant: 'destructive',
});
}
};
// Handle camera capture
const handleCameraCapture = async (file: File) => {
try {
const photoTags = await uploadFile(file);
const url = photoTags[0]?.[1];
if (url) {
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
setShowCamera(false);
clearError('photo');
toast({
title: t('spotUi.photoCaptured'),
description: t('spotUi.photoCapturedDesc'),
});
}
} catch (error) {
toast({
title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
variant: 'destructive',
});
}
};
const removeImage = (url: string) => {
setImages((prev) => prev.filter((img) => img !== url));
};
// If the pin is already a known spot, a photo + note contribution is much
// lower friction than creating a duplicate. This uses the same kind as the
// spot detail page's Add Photo flow.
const handleContribute = async (target: SkateSpot) => {
if (images.length === 0) {
toast({
title: t('createSpot.photoRequired'),
description: t('spotUi.uploadPhotoFirst'),
variant: 'destructive',
});
return;
}
try {
const spotRef = `${SKATE_SPOT_KIND}:${target.pubkey}:${target.identifier}`;
for (const imageUrl of images) {
await createEvent({
kind: PHOTO_CONTRIBUTION_KIND,
content: description.trim(),
tags: [
['url', imageUrl],
['a', spotRef],
['m', mimeForImageUrl(imageUrl)],
['alt', t('spotUi.altSkateSpot', { title: target.title })],
],
});
}
draft.clear();
queryClient.invalidateQueries({ queryKey: ['spot-photos', target.pubkey, target.identifier] });
toast({
title: t('spotUi.photoContributed'),
description: t('spotUi.photoContributedDesc'),
});
navigate(`/spot/${target.pubkey}/${target.identifier}`);
} catch (error) {
console.error('Failed to contribute to spot:', error);
toast({
title: t('spotUi.failedToPublish'),
description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'),
variant: 'destructive',
});
}
};
const scrollToFirstError = (errs: FieldErrors) => {
const id = errs.location
? 'spot-location'
: errs.photo
? 'spot-photos'
: errs.title
? 'spot-title'
: errs.description
? 'spot-description'
: errs.type
? 'spot-type'
: null;
if (!id) return;
window.setTimeout(() => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = document.querySelector<HTMLElement>(`#${id} input, #${id} textarea, #${id} button`);
focusable?.focus({ preventScroll: true });
}, 0);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// 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);
scrollToFirstError(nextErrors);
return;
}
setErrors({});
// Look for an existing spot within 60 m and ask before creating a clone.
if (!dupConfirmedRef.current) {
const nearby = findNearby(lat, lng);
if (nearby.length > 0) {
setDupWarning(nearby);
window.setTimeout(() => {
document.getElementById('spot-nearby')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
return;
}
}
try {
// Generate geohash from coordinates (7 chars = ~153m precision)
const geohash = ngeohash.encode(lat, lng, 7);
// Create a unique identifier for the addressable event. Non-Latin titles
// still get a stable identifier instead of a bare timestamp.
const slug = title
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const identifier = `${slug || 'spot'}-${Date.now().toString(36)}`;
// Reverse geocode to get structured city/country tags (best-effort; the
// helper never throws). These power Sasquatch's city/country search and
// the /stats region breakdown. Capped so a slow provider can't make
// "Publish" feel stuck — a miss is covered by the stats' read-time
// derivation of an untagged pin's coords.
const emptyRegions: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' };
const geo = await Promise.race([
reverseGeocodeRegions(lat, lng),
new Promise<GeocodedRegions>((resolve) => {
window.setTimeout(() => resolve(emptyRegions), GEOCODE_BUDGET_MS);
}),
]);
const locationStr = location.trim() || [geo.city, geo.state, geo.country].filter(Boolean).join(', ');
// Build event tags
const eventTags: string[][] = [
['d', identifier], // Required for addressable events
['title', title.trim()],
['g', geohash],
['t', 'skatespot'], // Primary identifier for spot events
['access', accessType],
['lat', lat.toString()],
['lon', lng.toString()],
];
if (locationStr) {
eventTags.push(['location', locationStr]);
}
// Structured city/country/country_code tags — critical for Sasquatch relay
// search and the /stats region breakdown (country_code is the stable key).
pushRegionTags(eventTags, geo);
if (contactInfo.trim()) {
eventTags.push(['contact', contactInfo.trim()]);
}
// Shelter / weather-protection level. Always record the explicit value so
// the spot detail + map can show it; additionally add a queryable `t` tag
// for sheltered spots (indoor/covered/partial) so "dry spots" filtering and
// Sasquatch search work at the relay level. Outdoor is the implicit default
// and adds no `t` tag (keeps the common case from tagging everything).
eventTags.push(['shelter', shelter]);
if (SHELTERED_VALUES.includes(shelter)) {
eventTags.push(['t', shelter]);
}
// Add category as dedicated tag (new format)
if (selectedCategory) {
eventTags.push(['category', selectedCategory]);
// 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]);
}
// Add images
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility. Preserve the real image type
// where the URL exposes it; do not label every upload as JPEG.
eventTags.push(['imeta', `url ${imageUrl}`, `m ${mimeForImageUrl(imageUrl)}`]);
}
// Add alt tag for NIP-31 (human-readable description)
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
await createEvent({
kind: SKATE_SPOT_KIND,
content: description.trim(),
tags: eventTags,
});
draft.clear();
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
saveLastMapView(lat, lng, 15);
toast({
title: t('spotUi.spotAdded'),
description: t('spotUi.spotAddedDesc'),
action: (
<ToastAction
altText={t('home.addSpot')}
onClick={() => navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`)}
>
{t('home.addSpot')}
</ToastAction>
),
});
// Stay in the map flow: the map opens on the new pin instead of sending
// the contributor to a detail page they did not ask for.
navigate('/map');
} catch (error) {
console.error('Failed to create spot:', error);
toast({
title: t('spotUi.failedToPublish'),
description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'),
variant: 'destructive',
});
}
};
if (!user) {
return (
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h2 className="text-xl font-bold mb-2">{t('errors.loginRequired')}</h2>
<p className="text-muted-foreground mb-6">{t('createSpot.loginToAdd')}</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
);
}
const nearestDuplicate = dupWarning?.[0];
const defaultCenter = coords
? ([coords.lat, coords.lng] as [number, number])
: (() => {
const saved = getLastMapView();
if (saved) return [saved.lat, saved.lon] as [number, number];
const cached = getLastLocation();
return cached ? ([cached.lat, cached.lng] as [number, number]) : undefined;
})();
return (
<div className="container px-4 sm:px-6 py-6 pb-28 sm:py-8 md:pb-20 max-w-2xl">
<div className="mb-5">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-3">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('createSpot.backToMap')}
</Link>
<h1
className="text-3xl font-black uppercase neon-red"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
{t('createSpot.title')}
</h1>
<p className="text-gray-400 mt-1">{t('createSpot.subtitle')}</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 className="pb-3">
<CardTitle className="text-[#ff0040] uppercase font-black">{t('spots.spotDetails')}</CardTitle>
<CardDescription className="text-gray-400">{t('createSpot.requiredFields')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Photos first: most people already took the picture ─────── */}
<section id="spot-photos" className="space-y-2 scroll-mt-24">
<div className="flex items-baseline justify-between gap-3">
<Label className="text-base font-bold">
{t('spots.photos')} *
</Label>
<span className="text-[11px] text-muted-foreground">{t('createSpot.photoHint')}</span>
</div>
<div className="grid grid-cols-3 gap-2">
{images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<img src={url} alt={t('spotUi.spotPhotoAlt')} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(url)}
className="absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
<PhotoDropzone
onFiles={uploadImageFiles}
uploading={isUploading}
label={t('spotUi.addPhotos')}
className={cn('aspect-video', images.length === 0 && 'col-span-3 h-36')}
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCamera(true)}
disabled={isUploading}
className="gap-2 px-0 text-xs text-gray-400 hover:text-white"
>
<Camera className="h-4 w-4" />
{t('spotUi.takePhoto')}
</Button>
</div>
{errors.photo && <p className="text-xs font-medium text-red-400">{errors.photo}</p>}
</section>
{/* ── Location (defaults to map/cached area; live GPS is explicit) */}
<section id="spot-location" className="space-y-2 scroll-mt-24">
<Label className="text-base font-bold">{t('createSpot.locationLabel')} *</Label>
{!showLocationMap && hasCoords ? (
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex items-start gap-3">
<MapPin className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#ff0040]" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">
{location.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`}
</p>
<p className="text-[11px] text-gray-400">
{t('spotUi.exactPinPublic')}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setShowLocationMap(true)}>
{t('common.edit')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={getCurrentLocation}
disabled={isGettingLocation}
className="gap-1.5"
>
{isGettingLocation ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Crosshair className="h-3.5 w-3.5" />}
{isGettingLocation ? t('createSpot.gettingLocation') : t('createSpot.useCurrentLocation')}
</Button>
</div>
</div>
) : (
<>
<SpotLocationPicker
value={coords}
onChange={handlePick}
defaultCenter={defaultCenter}
/>
<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>
{hasCoords && (
<Button type="button" variant="ghost" size="sm" className="ml-auto" onClick={() => setShowLocationMap(false)}>
{t('common.done')}
</Button>
)}
</div>
<p className="text-[11px] leading-snug text-amber-300/90">
{t('spotUi.exactPinPublic')}
</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>
</>
)}
{errors.location && <p className="text-xs font-medium text-red-400">{errors.location}</p>}
</section>
{/* Nearby existing spot: one calm shortcut, not a blocking modal. */}
{nearestDuplicate && (
<section id="spot-nearby" className="scroll-mt-24 rounded-2xl border 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">
{t('spotUi.duplicateOne')}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Link
to={`/spot/${nearestDuplicate.spot.pubkey}/${nearestDuplicate.spot.identifier}`}
className="text-xs font-bold text-[#ccff00] hover:opacity-80"
>
{nearestDuplicate.spot.title || t('spotUi.untitledSpot')} ·{' '}
{t('spotUi.metresAway', { distance: Math.round(nearestDuplicate.km * 1000) })}
</Link>
</div>
{images.length > 0 && (
<Button
type="button"
size="sm"
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
disabled={isPublishing}
onClick={() => handleContribute(nearestDuplicate.spot)}
>
{t('spotUi.contributePhoto')}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="text-gray-300"
onClick={() => {
dupConfirmedRef.current = true;
setDupWarning(null);
void handleSubmit();
}}
>
{t('spotUi.differentSpotPublishAnyway')}
</Button>
</section>
)}
{/* ── Title ─────────────────────────────────────────────── */}
<section id="spot-title" className="space-y-2 scroll-mt-24">
<Label htmlFor="title">{t('spots.spotName')} *</Label>
<Input
id="title"
placeholder={t('createSpot.namePlaceholder')}
value={title}
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>}
</section>
{/* ── Spot type: compact chips, one tap ─────────────────── */}
<section id="spot-type" className="space-y-2 scroll-mt-24">
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
<div className="flex gap-1.5 overflow-x-auto pb-1 scrollbar-hide">
{spotCategories.map((category) => {
const selected = selectedCategory === category.value;
return (
<button
key={category.value}
type="button"
onClick={() => handleCategoryChange(category.value)}
className={cn(
'flex min-h-11 flex-shrink-0 items-center gap-1.5 rounded-full border-2 px-3 text-xs font-bold transition-colors touch-manipulation',
selected
? 'border-[#ff0040] bg-[#ff0040]/15 text-white'
: 'border-gray-700 text-gray-300 hover:border-[#ff0040]/50',
)}
>
<span>{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</span>
{selected && <Check className="h-3.5 w-3.5 text-[#ff0040]" />}
</button>
);
})}
</div>
{errors.type && <p className="text-xs font-medium text-red-400">{errors.type}</p>}
</section>
{/* ── Note: one line is enough ───────────────────────────── */}
<section id="spot-description" className="space-y-2 scroll-mt-24">
<Label htmlFor="description">{t('spots.description')} *</Label>
<Textarea
id="description"
placeholder={t('createSpot.descriptionPlaceholder')}
value={description}
onChange={(e) => { setDescription(e.target.value); clearError('description'); }}
rows={2}
className={cn(errors.description && 'border-red-500/60')}
/>
{errors.description && <p className="text-xs font-medium text-red-400">{errors.description}</p>}
</section>
{/* ── Access: one row, default public ───────────────────── */}
<section className="space-y-2">
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
<div className="grid grid-cols-3 gap-2">
{(['public', 'private', 'locals'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setAccessType(value)}
className={cn(
'flex min-h-11 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-[10px] font-bold leading-tight transition-colors touch-manipulation',
accessType === value
? 'border-[#ff0040] bg-[#ff0040]/15 text-white'
: 'border-gray-700 text-gray-300 hover:border-[#ff0040]/50',
)}
>
<span>{value === 'public' ? '🌍' : value === 'private' ? '🔒' : '👥'}</span>
<span className="truncate">{t(ACCESS_TYPE_KEYS[value])}</span>
</button>
))}
</div>
<p className="text-[11px] text-gray-400">
{accessType === 'private'
? t('createSpot.privateDesc')
: accessType === 'locals'
? t('createSpot.localsDesc')
: t('createSpot.publicDesc')}
</p>
</section>
{/* ── More details: never required on the fast path ──────── */}
<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-5 px-4 pb-4">
<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>
{(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)}
/>
<p className="text-xs text-gray-400">
{accessType === 'private' ? t('createSpot.contactHintPrivate') : t('createSpot.contactHintLocals')}
</p>
</div>
)}
<div className="space-y-3">
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
<p className="-mt-1 text-xs text-gray-400">{t('spotUi.shelterHint')}</p>
<div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => {
const selected = shelter === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setShelter(option.value)}
className={cn(
'flex min-h-11 items-center gap-2 rounded-xl border-2 px-3 text-left text-xs font-bold transition-colors touch-manipulation',
selected
? 'border-[#ff0040] bg-[#ff0040]/15 text-white'
: 'border-gray-700 text-gray-300 hover:border-[#ff0040]/50',
)}
>
<span>{option.emoji}</span>
<span className="min-w-0 flex-1 truncate">
{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}
</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0 text-[#ff0040]" />}
</button>
);
})}
</div>
{shelter !== DEFAULT_SHELTER && (
<p className="text-[11px] text-gray-400">
{SHELTER_DESC_KEYS[shelter] ? t(SHELTER_DESC_KEYS[shelter]) : ''}
</p>
)}
</div>
<div className="space-y-3">
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
<p className="-mt-1 text-xs text-gray-400">{t('spotUi.tagsHint', { max: MAX_TAGS })}</p>
<TagInput tags={tags} onChange={setTags} placeholder={t('spotUi.typeToSearchTags')} maxTags={MAX_TAGS} />
</div>
</CollapsibleContent>
</Collapsible>
<div className="fixed inset-x-0 bottom-[calc(3.5rem+env(safe-area-inset-bottom,0px))] z-40 border-t border-white/10 bg-black/95 backdrop-blur md:bottom-0">
<div className="mx-auto flex max-w-2xl items-center px-4 py-3">
<Button type="submit" className="w-full h-12 text-base" disabled={isPublishing || isUploading}>
{isPublishing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('createSpot.publishing')}
</>
) : (
<>
<MapPin className="mr-2 h-4 w-4" />
{t('home.addSpot')}
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</form>
<CameraCapture
open={showCamera}
onOpenChange={setShowCamera}
onCapture={handleCameraCapture}
isUploading={isUploading}
/>
</div>
);
}