feat(create-spot): make address + access type required; move them out of the optional collapsible

This commit is contained in:
SkateSpots 2026-10-07 11:30:55 +07:00
parent c08592144a
commit 30a0aaa42b
5 changed files with 40 additions and 26 deletions

View file

@ -1127,6 +1127,13 @@ session with **no other memory of this project**. So:
stall signing (a miss falls back to the stats' read-time region derivation).
⚠️ A geohash-prefix nearby query is still impossible — relays only exact-match
`#g` — so the 2000-spot cache remains the mechanism.
**Required set (2026-10-07):** pin + name + description + **address** +
**access type** + photo. Address and access type were **moved OUT of the
"More details (optional)" collapsible** into the required flow (the collapsible
now holds only Shelter + Tags) — required fields must not be hidden under
"optional". Access keeps its **Public default** (so it is always satisfied; the
`*` marks membership in the required set), and Address has real inline
validation (`createSpot.addressRequired`).
- **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

View file

@ -509,6 +509,7 @@ nav: {
photoHint: 'a photo is required to publish',
nameRequired: 'Please enter a name for this spot.',
descriptionRequired: 'Please describe this spot.',
addressRequired: 'Please add an address or area name.',
photoRequired: 'Please add at least one photo.',
draftTitle: 'You have an unsaved draft',
draftBody: 'Pick up where you left off, or discard it.',

View file

@ -509,6 +509,7 @@ nav: {
photoHint: 'se requiere una foto para publicar',
nameRequired: 'Ponle un nombre a este spot.',
descriptionRequired: 'Describe este spot.',
addressRequired: 'Añade una dirección o nombre de zona.',
photoRequired: 'Añade al menos una foto.',
draftTitle: 'Tienes un borrador sin guardar',
draftBody: 'Continúa donde lo dejaste o descártalo.',

View file

@ -369,6 +369,7 @@ const thBatch5 = {
photoHint: 'ต้องมีรูปจึงจะเผยแพร่ได้',
nameRequired: 'กรุณาตั้งชื่อสปอตนี้',
descriptionRequired: 'กรุณาอธิบายสปอตนี้',
addressRequired: 'กรุณาเพิ่มที่อยู่หรือชื่อพื้นที่',
photoRequired: 'กรุณาเพิ่มรูปอย่างน้อยหนึ่งรูป',
draftTitle: 'คุณมีฉบับร่างที่ยังไม่ได้บันทึก',
draftBody: 'ทำต่อจากที่ค้างไว้ หรือลบทิ้ง',

View file

@ -45,6 +45,7 @@ import { Link } from 'react-router-dom';
type FieldErrors = {
title?: string;
description?: string;
address?: string;
location?: string;
type?: string;
photo?: string;
@ -342,6 +343,7 @@ export default function CreateSpotPage() {
const nextErrors: FieldErrors = {};
if (!title.trim()) nextErrors.title = t('createSpot.nameRequired');
if (!description.trim()) nextErrors.description = t('createSpot.descriptionRequired');
if (!location.trim()) nextErrors.address = t('createSpot.addressRequired');
if (!hasCoords) nextErrors.location = t('spotUi.locationRequiredDesc');
if (!selectedCategory) nextErrors.type = t('spotUi.spotTypeRequiredDesc');
if (images.length === 0) nextErrors.photo = t('createSpot.photoRequired');
@ -613,6 +615,19 @@ export default function CreateSpotPage() {
</Collapsible>
</div>
{/* Address (required) — the human-readable place name. */}
<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); clearError('address'); }}
className={cn(errors.address && 'border-red-500/60')}
/>
{errors.address && <p className="text-xs font-medium text-red-400">{errors.address}</p>}
</div>
{/* Duplicate warning — appears as soon as the pin lands, right
below the map, instead of only on submit. */}
{dupWarning && (
@ -776,34 +791,9 @@ 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>
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
<div className="space-y-2">
{/* Public */}
<Card
@ -904,6 +894,20 @@ export default function CreateSpotPage() {
</div>
)}
{/* ── 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">
{/* Shelter / weather protection */}
<div className="space-y-3">
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>