Improve validation UX for listings and optional calendars

This commit is contained in:
Tero Halla-aho 2025-12-06 23:00:43 +02:00
parent a50ee24730
commit 4e344b892e
3 changed files with 49 additions and 35 deletions

View file

@ -208,10 +208,6 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); return NextResponse.json({ error: 'Missing slug' }, { status: 400 });
} }
if (!saveDraft && (!country || !region || !city || !contactEmail || !contactName)) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? null : Number(body.maxGuests); const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? null : Number(body.maxGuests);
const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? null : Number(body.bedrooms); const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? null : Number(body.bedrooms);
const beds = body.beds === undefined || body.beds === null || body.beds === '' ? null : Number(body.beds); const beds = body.beds === undefined || body.beds === null || body.beds === '' ? null : Number(body.beds);
@ -247,7 +243,7 @@ export async function POST(req: Request) {
}); });
} }
if (!translationsInput.length) { if (!translationsInput.length && !saveDraft) {
return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 }); return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 });
} }
@ -311,11 +307,20 @@ export async function POST(req: Request) {
} }
if (!saveDraft) { if (!saveDraft) {
if (!country || !region || !city || !contactEmail || !contactName || !maxGuests || !bedrooms || !beds || !bathrooms) { const missingFields: string[] = [];
return NextResponse.json({ error: 'Missing required fields for publish' }, { status: 400 }); if (!country) missingFields.push('country');
} if (!region) missingFields.push('region');
if (!parsedImages.length) { if (!city) missingFields.push('city');
return NextResponse.json({ error: 'At least one image is required to publish' }, { status: 400 }); if (!contactEmail) missingFields.push('contactEmail');
if (!contactName) missingFields.push('contactName');
if (!maxGuests) missingFields.push('maxGuests');
if (!bedrooms && bedrooms !== 0) missingFields.push('bedrooms');
if (!beds) missingFields.push('beds');
if (!bathrooms) missingFields.push('bathrooms');
if (!translationsInput.length) missingFields.push('translations');
if (!parsedImages.length) missingFields.push('images');
if (missingFields.length) {
return NextResponse.json({ error: `Missing required fields: ${missingFields.join(', ')}` }, { status: 400 });
} }
} }
@ -362,7 +367,8 @@ export async function POST(req: Request) {
externalUrl: body.externalUrl ?? null, externalUrl: body.externalUrl ?? null,
published: status === ListingStatus.PUBLISHED, published: status === ListingStatus.PUBLISHED,
isSample, isSample,
translations: { translations: translationsInput.length
? {
create: translationsInput.map((t: TranslationInput) => ({ create: translationsInput.map((t: TranslationInput) => ({
locale: t.locale, locale: t.locale,
slug: t.slug || slug, slug: t.slug || slug,
@ -370,7 +376,8 @@ export async function POST(req: Request) {
description: t.description, description: t.description,
teaser: t.teaser ?? null, teaser: t.teaser ?? null,
})), })),
}, }
: undefined,
images: parsedImages.length images: parsedImages.length
? { ? {
create: parsedImages, create: parsedImages,

View file

@ -315,14 +315,18 @@ export default function NewListingPage() {
teaser: translations[loc].teaser.trim(), teaser: translations[loc].teaser.trim(),
})).filter((t) => t.title && t.description); })).filter((t) => t.title && t.description);
if (translationEntries.length === 0) { const missing: string[] = [];
setError(t('translationMissing')); if (!slug.trim()) missing.push(t('slugLabel'));
setLoading(false); if (!saveDraft && translationEntries.length === 0) missing.push(t('translationMissing'));
return; if (!saveDraft && !country.trim()) missing.push(t('countryLabel'));
} if (!saveDraft && !region.trim()) missing.push(t('regionLabel'));
if (!saveDraft && !city.trim()) missing.push(t('cityLabel'));
if (!saveDraft && selectedImages.length === 0) { if (!saveDraft && !streetAddress.trim()) missing.push(t('streetAddressLabel'));
setError(t('imagesRequired')); if (!saveDraft && !contactName.trim()) missing.push(t('contactNameLabel'));
if (!saveDraft && !contactEmail.trim()) missing.push(t('contactEmailLabel'));
if (!saveDraft && selectedImages.length === 0) missing.push(t('imagesLabel'));
if (missing.length) {
setError(t('missingFields', { fields: missing.join(', ') }));
setLoading(false); setLoading(false);
return; return;
} }
@ -471,11 +475,11 @@ export default function NewListingPage() {
<h3 style={{ margin: 0 }}>{t('localeSectionTitle')}</h3> <h3 style={{ margin: 0 }}>{t('localeSectionTitle')}</h3>
<label> <label>
{t('titleLabel')} {t('titleLabel')}
<input value={translations[currentLocale].title} onChange={(e) => updateTranslation(currentLocale, 'title', e.target.value)} required /> <input value={translations[currentLocale].title} onChange={(e) => updateTranslation(currentLocale, 'title', e.target.value)} />
</label> </label>
<label> <label>
{t('descriptionLabel')} {t('descriptionLabel')}
<textarea value={translations[currentLocale].description} onChange={(e) => updateTranslation(currentLocale, 'description', e.target.value)} required rows={6} /> <textarea value={translations[currentLocale].description} onChange={(e) => updateTranslation(currentLocale, 'description', e.target.value)} rows={6} />
</label> </label>
<label> <label>
{t('teaserLabel')} {t('teaserLabel')}
@ -563,20 +567,20 @@ export default function NewListingPage() {
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}> <div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<label> <label>
{t('countryLabel')} {t('countryLabel')}
<input value={country} onChange={(e) => setCountry(e.target.value)} required /> <input value={country} onChange={(e) => setCountry(e.target.value)} />
</label> </label>
<label> <label>
{t('regionLabel')} {t('regionLabel')}
<input value={region} onChange={(e) => setRegion(e.target.value)} required /> <input value={region} onChange={(e) => setRegion(e.target.value)} />
</label> </label>
<label> <label>
{t('cityLabel')} {t('cityLabel')}
<input value={city} onChange={(e) => setCity(e.target.value)} required /> <input value={city} onChange={(e) => setCity(e.target.value)} />
</label> </label>
</div> </div>
<label> <label>
{t('streetAddressLabel')} {t('streetAddressLabel')}
<input value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} required /> <input value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} />
</label> </label>
<label> <label>
{t('addressNoteLabel')} {t('addressNoteLabel')}
@ -584,11 +588,11 @@ export default function NewListingPage() {
</label> </label>
<label> <label>
{t('contactNameLabel')} {t('contactNameLabel')}
<input value={contactName} onChange={(e) => setContactName(e.target.value)} required /> <input value={contactName} onChange={(e) => setContactName(e.target.value)} />
</label> </label>
<label> <label>
{t('contactEmailLabel')} {t('contactEmailLabel')}
<input type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} required /> <input type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
</label> </label>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}> <div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label> <label>

View file

@ -162,6 +162,7 @@ const baseMessages = {
aiApplySuccess: 'Translations updated from AI response.', aiApplySuccess: 'Translations updated from AI response.',
translationMissing: 'Add at least one language with a title and description.', translationMissing: 'Add at least one language with a title and description.',
saveDraft: 'Save draft', saveDraft: 'Save draft',
missingFields: 'Missing: {fields}',
loginToCreate: 'Please log in first to create a listing.', loginToCreate: 'Please log in first to create a listing.',
slugLabel: 'Slug', slugLabel: 'Slug',
slugHelp: 'Unique link name, use lowercase letters and hyphens only (e.g. lake-cabin).', slugHelp: 'Unique link name, use lowercase letters and hyphens only (e.g. lake-cabin).',
@ -329,6 +330,7 @@ const baseMessages = {
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.', aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.', translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
saveDraft: 'Tallenna luonnos', saveDraft: 'Tallenna luonnos',
missingFields: 'Puuttuu: {fields}',
ctaViewSample: 'Katso esimerkkikohde', ctaViewSample: 'Katso esimerkkikohde',
ctaHealth: 'Tarkista health-päätepiste', ctaHealth: 'Tarkista health-päätepiste',
ctaBrowse: 'Selaa kohteita', ctaBrowse: 'Selaa kohteita',
@ -618,6 +620,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.', aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.',
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.', translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
saveDraft: 'Spara utkast', saveDraft: 'Spara utkast',
missingFields: 'Saknas: {fields}',
slugChecking: 'Kontrollerar tillgänglighet…', slugChecking: 'Kontrollerar tillgänglighet…',
slugAvailable: 'Sluggen är ledig', slugAvailable: 'Sluggen är ledig',
slugTaken: 'Sluggen används redan', slugTaken: 'Sluggen används redan',