From 7dcc39f36e87b379994929946ec4d0b94635f2ff Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Thu, 27 Nov 2025 23:31:49 +0200 Subject: [PATCH] Add multi-language listing editor with AI helper --- PROGRESS.md | 1 + app/api/listings/route.ts | 49 +++++++++--- app/listings/new/page.tsx | 162 +++++++++++++++++++++++++++++++++----- lib/i18n.ts | 28 +++++++ 4 files changed, 209 insertions(+), 31 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 6ba0924..7bb24f9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -65,3 +65,4 @@ - Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`. - Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging. - Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section. +- Listing creation form now supports editing all locales at once with language tabs, per-locale readiness badges, and an AI JSON helper to translate and apply copy across languages; API accepts multiple translations in one request. diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index f00a41a..2017f1f 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -184,9 +184,6 @@ export async function POST(req: Request) { const body = await req.json(); const slug = String(body.slug ?? '').trim().toLowerCase(); - const locale = String(body.locale ?? 'en').toLowerCase(); - const title = String(body.title ?? '').trim(); - const description = String(body.description ?? '').trim(); const country = String(body.country ?? '').trim(); const region = String(body.region ?? '').trim(); const city = String(body.city ?? '').trim(); @@ -194,7 +191,7 @@ export async function POST(req: Request) { const contactName = String(body.contactName ?? '').trim(); const contactEmail = String(body.contactEmail ?? '').trim(); - if (!slug || !title || !description || !country || !region || !city || !contactEmail || !contactName) { + if (!slug || !country || !region || !city || !contactEmail || !contactName) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } @@ -204,6 +201,36 @@ export async function POST(req: Request) { const bathrooms = Number(body.bathrooms ?? 1); const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null; const calendarUrls = normalizeCalendarUrls(body.calendarUrls); + const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; + const translationsInput = + translationsInputRaw + .map((item: any) => ({ + locale: String(item.locale ?? '').toLowerCase(), + title: typeof item.title === 'string' ? item.title.trim() : '', + description: typeof item.description === 'string' ? item.description.trim() : '', + teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null, + slug: String(item.slug ?? slug).trim().toLowerCase(), + })) + .filter((t: any) => t.locale && t.title && t.description) || []; + + const fallbackLocale = String(body.locale ?? 'en').toLowerCase(); + const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : ''; + const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : ''; + const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : null; + + if (translationsInput.length === 0 && fallbackTranslationTitle && fallbackTranslationDescription) { + translationsInput.push({ + locale: fallbackLocale, + title: fallbackTranslationTitle, + description: fallbackTranslationDescription, + teaser: fallbackTranslationTeaser, + slug, + }); + } + + if (!translationsInput.length) { + return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 }); + } const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { @@ -305,13 +332,13 @@ export async function POST(req: Request) { published: status === ListingStatus.PUBLISHED, isSample, translations: { - create: { - locale, - slug, - title, - description, - teaser: body.teaser ?? null, - }, + create: translationsInput.map((t) => ({ + locale: t.locale, + slug: t.slug || slug, + title: t.title, + description: t.description, + teaser: t.teaser ?? null, + })), }, images: parsedImages.length ? { diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index 8eeb208..ed8e92a 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useI18n } from '../../components/I18nProvider'; import type { Locale } from '../../../lib/i18n'; @@ -14,14 +14,17 @@ type SelectedImage = { const MAX_IMAGES = 6; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image +const SUPPORTED_LOCALES: Locale[] = ['en', 'fi']; +type LocaleFields = { title: string; description: string; teaser: string }; export default function NewListingPage() { const { t, locale: uiLocale } = useI18n(); const [slug, setSlug] = useState(''); - const [locale, setLocale] = useState(uiLocale); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [teaser, setTeaser] = useState(''); + const [currentLocale, setCurrentLocale] = useState(uiLocale as Locale); + const [translations, setTranslations] = useState>({ + en: { title: '', description: '', teaser: '' }, + fi: { title: '', description: '', teaser: '' }, + }); const [country, setCountry] = useState('Finland'); const [region, setRegion] = useState(''); const [city, setCity] = useState(''); @@ -54,9 +57,10 @@ export default function NewListingPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [isAuthed, setIsAuthed] = useState(false); + const [aiResponse, setAiResponse] = useState(''); useEffect(() => { - setLocale(uiLocale); + setCurrentLocale(uiLocale as Locale); }, [uiLocale]); useEffect(() => { @@ -122,6 +126,71 @@ export default function NewListingPage() { { key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue }, ]; + function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) { + setTranslations((prev) => ({ + ...prev, + [locale]: { ...prev[locale], [field]: value }, + })); + } + + function localeStatus(locale: Locale) { + const { title, description } = translations[locale]; + if (!title && !description) return 'missing'; + if (title && description) return 'ready'; + return 'partial'; + } + + const aiPrompt = useMemo(() => { + const payload = { + task: 'Translate this localization file for a holiday rental listing.', + instructions: [ + 'Preserve meaning, tone, numbers, and any markup.', + 'Return valid JSON only with the same keys.', + 'Fill missing translations; keep existing text unchanged.', + ], + sourceLocale: currentLocale, + targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale), + locales: SUPPORTED_LOCALES.reduce( + (acc, loc) => ({ + ...acc, + [loc]: { + title: translations[loc].title, + teaser: translations[loc].teaser, + description: translations[loc].description, + }, + }), + {} as Record + ), + }; + return JSON.stringify(payload, null, 2); + }, [translations, currentLocale]); + + function applyAiResponse() { + setMessage(null); + setError(null); + try { + const parsed = JSON.parse(aiResponse); + if (!parsed?.locales || typeof parsed.locales !== 'object') { + throw new Error('Missing locales'); + } + const next = { ...translations }; + SUPPORTED_LOCALES.forEach((loc) => { + if (parsed.locales[loc]) { + const incoming = parsed.locales[loc]; + next[loc] = { + title: typeof incoming.title === 'string' ? incoming.title : next[loc].title, + teaser: typeof incoming.teaser === 'string' ? incoming.teaser : next[loc].teaser, + description: typeof incoming.description === 'string' ? incoming.description : next[loc].description, + }; + } + }); + setTranslations(next); + setMessage(t('aiApplySuccess')); + } catch (err) { + setError(t('aiApplyError')); + } + } + function parseImages(): ImageInput[] { return selectedImages.map((img) => ({ data: img.dataUrl, @@ -136,6 +205,19 @@ export default function NewListingPage() { setError(null); setLoading(true); try { + const translationEntries = SUPPORTED_LOCALES.map((loc) => ({ + locale: loc, + title: translations[loc].title.trim(), + description: translations[loc].description.trim(), + teaser: translations[loc].teaser.trim(), + })).filter((t) => t.title && t.description); + + if (translationEntries.length === 0) { + setError(t('translationMissing')); + setLoading(false); + return; + } + if (selectedImages.length === 0) { setError(t('imagesRequired')); setLoading(false); @@ -147,10 +229,11 @@ export default function NewListingPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug, - locale, - title, - description, - teaser, + translations: translationEntries.map((t) => ({ + ...t, + slug, + teaser: t.teaser || null, + })), country, region, city, @@ -187,9 +270,10 @@ export default function NewListingPage() { } else { setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status })); setSlug(''); - setTitle(''); - setDescription(''); - setTeaser(''); + setTranslations({ + en: { title: '', description: '', teaser: '' }, + fi: { title: '', description: '', teaser: '' }, + }); setRegion(''); setCity(''); setStreetAddress(''); @@ -201,6 +285,7 @@ export default function NewListingPage() { setCalendarUrls(''); setSelectedImages([]); setCoverImageIndex(1); + setAiResponse(''); } } catch (err) { setError('Failed to create listing'); @@ -226,22 +311,59 @@ export default function NewListingPage() { {t('slugLabel')} setSlug(e.target.value)} required /> - +
+
+ {t('languageTabsLabel')} + {t('languageTabsHint')} +
+
+ {SUPPORTED_LOCALES.map((loc) => { + const status = localeStatus(loc); + const badge = + status === 'ready' ? t('localeReady') : status === 'partial' ? t('localePartial') : t('localeMissing'); + return ( + + ); + })} +
+