From 380c9abf38753a3257dfcea11df2ccb23a4bc282 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Mon, 15 Dec 2025 21:49:20 +0200 Subject: [PATCH 1/2] Add Trivy update reminder and ignore sensitive paths --- .trivyignore | 5 +++++ deploy/build.sh | 15 ++++++++++++++- scripts/run-test-suite.sh | 6 +++++- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .trivyignore diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..3ebad0d --- /dev/null +++ b/.trivyignore @@ -0,0 +1,5 @@ +creds/** +reports/** +*.pem +*.key +*.enc diff --git a/deploy/build.sh b/deploy/build.sh index da9418c..12a8ba2 100755 --- a/deploy/build.sh +++ b/deploy/build.sh @@ -33,8 +33,21 @@ echo "Done. Last image: $IMAGE" # Trivy image scan (if available) if command -v trivy >/dev/null 2>&1; then + MIN_TRIVY_VERSION="0.56.0" + INSTALLED_TRIVY_VERSION="$(trivy --version 2>/dev/null | head -n1 | awk '{print $2}')" + if [[ -n "$INSTALLED_TRIVY_VERSION" ]] && [[ "$(printf '%s\n%s\n' "$MIN_TRIVY_VERSION" "$INSTALLED_TRIVY_VERSION" | sort -V | head -n1)" != "$MIN_TRIVY_VERSION" ]]; then + echo "Trivy version $INSTALLED_TRIVY_VERSION is older than recommended $MIN_TRIVY_VERSION." + echo "Update recommended: brew upgrade trivy # macOS" + echo "or: sudo apt-get install -y trivy # Debian/Ubuntu (Aqua repo)" + echo "or: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin" + fi + echo "Running Trivy scan on $IMAGE ..." - trivy image --exit-code 0 "$IMAGE" || true + TRIVY_IGNORE_ARGS=() + if [[ -f ".trivyignore" ]]; then + TRIVY_IGNORE_ARGS+=(--ignorefile .trivyignore) + fi + trivy image --exit-code 0 "${TRIVY_IGNORE_ARGS[@]}" "$IMAGE" || true else echo "Trivy not installed; skipping image scan." fi diff --git a/scripts/run-test-suite.sh b/scripts/run-test-suite.sh index 51db518..8a98fa5 100755 --- a/scripts/run-test-suite.sh +++ b/scripts/run-test-suite.sh @@ -190,7 +190,11 @@ TRIVY_MODE="${TRIVY_MODE:-fs}" if command -v trivy >/dev/null 2>&1; then log "Running Trivy (${TRIVY_MODE}) on ${TRIVY_TARGET}..." TRIVY_TXT="$RUN_DIR/trivy.txt" - if trivy "${TRIVY_MODE}" --severity HIGH,CRITICAL --timeout 5m "$TRIVY_TARGET" >"$TRIVY_TXT"; then + TRIVY_IGNORE_ARGS=() + if [ -f ".trivyignore" ]; then + TRIVY_IGNORE_ARGS+=(--ignorefile .trivyignore) + fi + if trivy "${TRIVY_MODE}" --severity HIGH,CRITICAL --timeout 5m "${TRIVY_IGNORE_ARGS[@]}" "$TRIVY_TARGET" >"$TRIVY_TXT"; then record_result "Trivy (${TRIVY_MODE})" "PASS" "report" "report: ${TRIVY_TXT}" else record_result "Trivy (${TRIVY_MODE})" "FAIL" "report" "report: ${TRIVY_TXT}" From 144db4f439e0d64d9250e26fb0d54a14b00b0213 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Mon, 15 Dec 2025 23:22:04 +0200 Subject: [PATCH 2/2] Add owner listing editing flow and API --- app/api/listings/[id]/route.ts | 280 ++++++++++++ app/api/listings/route.ts | 4 +- app/listings/edit/[id]/page.tsx | 731 ++++++++++++++++++++++++++++++++ app/listings/mine/page.tsx | 9 +- 4 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 app/api/listings/[id]/route.ts create mode 100644 app/listings/edit/[id]/page.tsx diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts new file mode 100644 index 0000000..5981401 --- /dev/null +++ b/app/api/listings/[id]/route.ts @@ -0,0 +1,280 @@ +import { ListingStatus, UserRole, UserStatus } from '@prisma/client'; +import { NextResponse } from 'next/server'; +import { prisma } from '../../../../lib/prisma'; +import { requireAuth } from '../../../../lib/jwt'; +import { parsePrice, normalizeCalendarUrls } from '../route'; // reuse helpers + +const MAX_IMAGES = 6; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const auth = await requireAuth(_req); + const listing = await prisma.listing.findFirst({ + where: { id: params.id, ownerId: auth.userId, removedAt: null }, + include: { + translations: true, + images: { orderBy: { order: 'asc' }, select: { id: true, altText: true, order: true, isCover: true, size: true, url: true, mimeType: true } }, + }, + }); + + if (!listing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ listing }); + } catch (err: any) { + const status = err?.message === 'Unauthorized' ? 401 : 500; + return NextResponse.json({ error: 'Failed to load listing' }, { status }); + } +} + +export async function PUT(req: Request, { params }: { params: { id: string } }) { + try { + const auth = await requireAuth(req); + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE) { + return NextResponse.json({ error: 'User not permitted to edit listings' }, { status: 403 }); + } + + const existing = await prisma.listing.findFirst({ + where: { id: params.id, ownerId: auth.userId, removedAt: null }, + include: { translations: true, images: true }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const body = await req.json(); + const saveDraft = Boolean(body.saveDraft); + + const baseSlug = String(body.slug ?? existing.translations[0]?.slug ?? '').trim().toLowerCase(); + if (!baseSlug) { + return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + } + + const country = String(body.country ?? existing.country ?? '').trim(); + const region = String(body.region ?? existing.region ?? '').trim(); + const city = String(body.city ?? existing.city ?? '').trim(); + const streetAddress = String(body.streetAddress ?? existing.streetAddress ?? '').trim(); + const contactName = String(body.contactName ?? existing.contactName ?? '').trim(); + const contactEmail = String(body.contactEmail ?? existing.contactEmail ?? '').trim(); + + const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? existing.maxGuests : Number(body.maxGuests); + const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? existing.bedrooms : Number(body.bedrooms); + const beds = body.beds === undefined || body.beds === null || body.beds === '' ? existing.beds : Number(body.beds); + const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === '' ? existing.bathrooms : Number(body.bathrooms); + const priceWeekdayEuros = body.priceWeekdayEuros === undefined ? existing.priceWeekdayEuros : parsePrice(body.priceWeekdayEuros); + const priceWeekendEuros = body.priceWeekendEuros === undefined ? existing.priceWeekendEuros : parsePrice(body.priceWeekendEuros); + const calendarUrls = normalizeCalendarUrls(body.calendarUrls ?? existing.calendarUrls); + + const translationsInputRaw = Array.isArray(body.translations) ? body.translations : null; + type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; + const translationsInput: TranslationInput[] = + 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 ?? baseSlug).trim().toLowerCase(), + })) || []; + + const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : existing.translations[0]?.title ?? ''; + const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : existing.translations[0]?.description ?? ''; + const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : existing.translations[0]?.teaser ?? null; + const fallbackLocale = String(body.locale ?? existing.translations[0]?.locale ?? 'en').toLowerCase(); + + if (translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft)) { + translationsInput.push({ + locale: fallbackLocale, + title: fallbackTranslationTitle ?? '', + description: fallbackTranslationDescription ?? '', + teaser: fallbackTranslationTeaser, + slug: baseSlug, + }); + } + + const imagesBody = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : null; + + if (!saveDraft) { + const missing: string[] = []; + if (!country) missing.push('country'); + if (!region) missing.push('region'); + if (!city) missing.push('city'); + if (!contactEmail) missing.push('contactEmail'); + if (!contactName) missing.push('contactName'); + if (!maxGuests) missing.push('maxGuests'); + if (!bedrooms && bedrooms !== 0) missing.push('bedrooms'); + if (!beds) missing.push('beds'); + if (!bathrooms) missing.push('bathrooms'); + if (!translationsInput.length && !existing.translations.length) missing.push('translations'); + const hasImagesIncoming = imagesBody && imagesBody.length > 0; + if (!hasImagesIncoming && existing.images.length === 0) missing.push('images'); + if (missing.length) { + return NextResponse.json({ error: `Missing required fields: ${missing.join(', ')}` }, { status: 400 }); + } + } + + let status = existing.status; + const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === UserRole.ADMIN); + if (saveDraft) { + status = ListingStatus.DRAFT; + } else if (existing.status === ListingStatus.PUBLISHED) { + status = ListingStatus.PUBLISHED; + } else { + status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; + } + + const parsedImages: { + data?: Buffer; + mimeType?: string | null; + size?: number | null; + url?: string | null; + altText?: string | null; + order: number; + isCover: boolean; + }[] = []; + + if (imagesBody) { + const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), imagesBody.length || 1); + + for (let idx = 0; idx < imagesBody.length; idx += 1) { + const img = imagesBody[idx]; + const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null; + const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null; + const rawData = typeof img.data === 'string' ? img.data : null; + const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null; + let mimeType = rawMime; + let buffer: Buffer | null = null; + + if (rawData) { + const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); + if (dataUrlMatch) { + mimeType = mimeType || dataUrlMatch[1] || null; + buffer = Buffer.from(dataUrlMatch[2], 'base64'); + } else { + buffer = Buffer.from(rawData, 'base64'); + } + } + + const size = buffer ? buffer.length : null; + if (size && size > MAX_IMAGE_BYTES) { + return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 }); + } + + if (!buffer && !rawUrl) { + continue; + } + + parsedImages.push({ + data: buffer ?? undefined, + mimeType: mimeType || 'image/jpeg', + size, + url: buffer ? null : rawUrl, + altText, + order: idx + 1, + isCover: coverImageIndex === idx + 1, + }); + } + + if (parsedImages.length && !parsedImages.some((img) => img.isCover)) { + parsedImages[0].isCover = true; + } + } + + const updateData: any = { + status, + approvedAt: status === ListingStatus.PUBLISHED ? existing.approvedAt ?? new Date() : null, + approvedById: status === ListingStatus.PUBLISHED && auth.role === UserRole.ADMIN ? auth.userId : existing.approvedById, + country: country || null, + region: region || null, + city: city || null, + streetAddress: streetAddress || null, + addressNote: body.addressNote ?? existing.addressNote ?? null, + latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : existing.latitude, + longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : existing.longitude, + maxGuests, + bedrooms, + beds, + bathrooms, + hasSauna: body.hasSauna === undefined ? existing.hasSauna : Boolean(body.hasSauna), + hasFireplace: body.hasFireplace === undefined ? existing.hasFireplace : Boolean(body.hasFireplace), + hasWifi: body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi), + petsAllowed: body.petsAllowed === undefined ? existing.petsAllowed : Boolean(body.petsAllowed), + byTheLake: body.byTheLake === undefined ? existing.byTheLake : Boolean(body.byTheLake), + hasAirConditioning: body.hasAirConditioning === undefined ? existing.hasAirConditioning : Boolean(body.hasAirConditioning), + hasKitchen: body.hasKitchen === undefined ? existing.hasKitchen : Boolean(body.hasKitchen), + hasDishwasher: body.hasDishwasher === undefined ? existing.hasDishwasher : Boolean(body.hasDishwasher), + hasWashingMachine: body.hasWashingMachine === undefined ? existing.hasWashingMachine : Boolean(body.hasWashingMachine), + hasBarbecue: body.hasBarbecue === undefined ? existing.hasBarbecue : Boolean(body.hasBarbecue), + hasMicrowave: body.hasMicrowave === undefined ? existing.hasMicrowave : Boolean(body.hasMicrowave), + hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking), + hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass), + evChargingAvailable: body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable), + priceWeekdayEuros, + priceWeekendEuros, + calendarUrls, + contactName: contactName || null, + contactEmail: contactEmail || null, + contactPhone: body.contactPhone ?? existing.contactPhone ?? null, + externalUrl: body.externalUrl ?? existing.externalUrl ?? null, + published: status === ListingStatus.PUBLISHED, + }; + + const tx: any[] = []; + + if (translationsInput && translationsInput.length) { + tx.push( + prisma.listingTranslation.deleteMany({ where: { listingId: existing.id } }), + prisma.listingTranslation.createMany({ + data: translationsInput.map((t) => ({ + listingId: existing.id, + locale: t.locale, + slug: t.slug || baseSlug, + title: t.title, + description: t.description, + teaser: t.teaser ?? null, + })), + }), + ); + } + + if (parsedImages && parsedImages.length) { + tx.push(prisma.listingImage.deleteMany({ where: { listingId: existing.id } })); + tx.push( + prisma.listingImage.createMany({ + data: parsedImages.map((img) => ({ + listingId: existing.id, + mimeType: img.mimeType || 'image/jpeg', + size: img.size ?? null, + url: img.url ?? null, + altText: img.altText ?? null, + order: img.order, + isCover: img.isCover, + data: img.data ?? null, + })), + }), + ); + } + + tx.unshift(prisma.listing.update({ where: { id: existing.id }, data: updateData })); + + await prisma.$transaction(tx); + + const updated = await prisma.listing.findUnique({ + where: { id: existing.id }, + include: { + translations: true, + images: { orderBy: { order: 'asc' }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true } }, + }, + }); + + return NextResponse.json({ ok: true, listing: updated }); + } catch (error: any) { + console.error('Update listing error', error); + const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to update listing'; + const status = error?.message === 'Unauthorized' ? 401 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index fdbc2ac..cf38991 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -26,7 +26,7 @@ function pickTranslation(translations: T[], locale return translations[0]; } -function normalizeCalendarUrls(input: unknown): string[] { +export function normalizeCalendarUrls(input: unknown): string[] { if (Array.isArray(input)) { return input .map((u) => (typeof u === 'string' ? u.trim() : '')) @@ -43,7 +43,7 @@ function normalizeCalendarUrls(input: unknown): string[] { return []; } -function parsePrice(value: unknown): number | null { +export function parsePrice(value: unknown): number | null { if (value === undefined || value === null || value === '') return null; const num = Number(value); if (Number.isNaN(num)) return null; diff --git a/app/listings/edit/[id]/page.tsx b/app/listings/edit/[id]/page.tsx new file mode 100644 index 0000000..3473c83 --- /dev/null +++ b/app/listings/edit/[id]/page.tsx @@ -0,0 +1,731 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { ListingStatus } from '@prisma/client'; +import { useI18n } from '../../../components/I18nProvider'; +import type { Locale } from '../../../../lib/i18n'; + +type ImageInput = { data?: string; url?: string; mimeType?: string; altText?: string }; +type SelectedImage = { + name: string; + size: number; + mimeType: string; + dataUrl: string; + isExisting?: boolean; +}; + +const MAX_IMAGES = 6; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image +const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv']; +type LocaleFields = { title: string; description: string; teaser: string }; + +export default function EditListingPage({ params }: { params: { id: string } }) { + const { t, locale: uiLocale } = useI18n(); + const [slug, setSlug] = useState(''); + const [currentLocale, setCurrentLocale] = useState(uiLocale as Locale); + const [translations, setTranslations] = useState>({ + en: { title: '', description: '', teaser: '' }, + fi: { title: '', description: '', teaser: '' }, + sv: { title: '', description: '', teaser: '' }, + }); + const [suggestedSlugs, setSuggestedSlugs] = useState>({ en: '', fi: '', sv: '' }); + const [country, setCountry] = useState('Finland'); + const [region, setRegion] = useState(''); + const [city, setCity] = useState(''); + const [streetAddress, setStreetAddress] = useState(''); + const [addressNote, setAddressNote] = useState(''); + const [latitude, setLatitude] = useState(''); + const [longitude, setLongitude] = useState(''); + const [contactName, setContactName] = useState(''); + const [contactEmail, setContactEmail] = useState(''); + const [maxGuests, setMaxGuests] = useState(''); + const [bedrooms, setBedrooms] = useState(''); + const [beds, setBeds] = useState(''); + const [bathrooms, setBathrooms] = useState(''); + const [priceWeekday, setPriceWeekday] = useState(''); + const [priceWeekend, setPriceWeekend] = useState(''); + const [hasSauna, setHasSauna] = useState(true); + const [hasFireplace, setHasFireplace] = useState(true); + const [hasWifi, setHasWifi] = useState(true); + const [petsAllowed, setPetsAllowed] = useState(false); + const [byTheLake, setByTheLake] = useState(false); + const [hasAirConditioning, setHasAirConditioning] = useState(false); + const [hasKitchen, setHasKitchen] = useState(true); + const [hasDishwasher, setHasDishwasher] = useState(false); + const [hasWashingMachine, setHasWashingMachine] = useState(false); + const [hasBarbecue, setHasBarbecue] = useState(false); + const [hasMicrowave, setHasMicrowave] = useState(false); + const [hasFreeParking, setHasFreeParking] = useState(false); + const [hasSkiPass, setHasSkiPass] = useState(false); + const [evChargingAvailable, setEvChargingAvailable] = useState(false); + const [calendarUrls, setCalendarUrls] = useState(''); + const [selectedImages, setSelectedImages] = useState([]); + const [coverImageIndex, setCoverImageIndex] = useState(1); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle'); + const [aiResponse, setAiResponse] = useState(''); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); + const [aiLoading, setAiLoading] = useState(false); + const [showManualAi, setShowManualAi] = useState(false); + const [initialStatus, setInitialStatus] = useState(null); + const [loadingListing, setLoadingListing] = useState(true); + + useEffect(() => { + setCurrentLocale(uiLocale as Locale); + }, [uiLocale]); + + useEffect(() => { + async function loadListing() { + setLoadingListing(true); + try { + const res = await fetch(`/api/listings/${params.id}`, { cache: 'no-store' }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Failed to load listing'); + } + const listing = data.listing; + setInitialStatus(listing.status as ListingStatus); + const translationMap = { ...translations }; + const slugMap = { ...suggestedSlugs }; + SUPPORTED_LOCALES.forEach((loc) => { + const found = listing.translations.find((t: any) => t.locale === loc); + if (found) { + translationMap[loc] = { + title: found.title || '', + description: found.description || '', + teaser: found.teaser || '', + }; + slugMap[loc] = found.slug || ''; + } + }); + setTranslations(translationMap); + setSuggestedSlugs(slugMap); + const primarySlug = slugMap[currentLocale] || slugMap.en || slugMap.fi || slugMap.sv || listing.translations[0]?.slug || ''; + setSlug(primarySlug); + setCountry(listing.country || ''); + setRegion(listing.region || ''); + setCity(listing.city || ''); + setStreetAddress(listing.streetAddress || ''); + setAddressNote(listing.addressNote || ''); + setLatitude(listing.latitude ?? ''); + setLongitude(listing.longitude ?? ''); + setContactName(listing.contactName || ''); + setContactEmail(listing.contactEmail || ''); + setMaxGuests(listing.maxGuests ?? ''); + setBedrooms(listing.bedrooms ?? ''); + setBeds(listing.beds ?? ''); + setBathrooms(listing.bathrooms ?? ''); + setPriceWeekday(listing.priceWeekdayEuros ?? ''); + setPriceWeekend(listing.priceWeekendEuros ?? ''); + setHasSauna(listing.hasSauna); + setHasFireplace(listing.hasFireplace); + setHasWifi(listing.hasWifi); + setPetsAllowed(listing.petsAllowed); + setByTheLake(listing.byTheLake); + setHasAirConditioning(listing.hasAirConditioning); + setHasKitchen(listing.hasKitchen); + setHasDishwasher(listing.hasDishwasher); + setHasWashingMachine(listing.hasWashingMachine); + setHasBarbecue(listing.hasBarbecue); + setHasMicrowave(listing.hasMicrowave); + setHasFreeParking(listing.hasFreeParking); + setHasSkiPass(listing.hasSkiPass); + setEvChargingAvailable(listing.evChargingAvailable); + setCalendarUrls((listing.calendarUrls || []).join('\n')); + if (listing.images?.length) { + const coverIdx = listing.images.find((img: any) => img.isCover)?.order ?? 1; + setCoverImageIndex(coverIdx); + } + } catch (err: any) { + setError(err.message || 'Failed to load listing'); + } finally { + setLoadingListing(false); + } + } + loadListing(); + }, [params.id, currentLocale]); + + 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.', + 'Suggest localized slugs based on the title/description; keep them URL-friendly (kebab-case).', + 'If teaser or slug is empty, propose one; otherwise keep the existing value.', + ], + 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, + slug: suggestedSlugs[loc] || slug, + }, + }), + {} as Record, + ), + }; + return JSON.stringify(payload, null, 2); + }, [translations, currentLocale, suggestedSlugs, slug]); + + async function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve({ + name: file.name, + size: file.size, + mimeType: file.type || 'image/jpeg', + dataUrl: String(reader.result), + isExisting: false, + }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + function parseImages(): ImageInput[] { + return selectedImages.map((img, idx) => { + const isData = img.dataUrl.startsWith('data:'); + const base: ImageInput = { + mimeType: img.mimeType, + altText: img.name.replace(/[-_]/g, ' '), + }; + if (isData) { + base.data = img.dataUrl; + } else { + base.url = img.dataUrl; + } + return base; + }); + } + + async function handleFileChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + setError(null); + setMessage(null); + if (!files.length) return; + + if (files.length > MAX_IMAGES) { + setError(t('imagesTooMany', { count: MAX_IMAGES })); + return; + } + + const tooLarge = files.find((f) => f.size > MAX_IMAGE_BYTES); + if (tooLarge) { + setError(t('imagesTooLarge', { sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })); + return; + } + + try { + const parsed = await Promise.all(files.map(readFileAsDataUrl)); + setSelectedImages(parsed); + setCoverImageIndex(1); + } catch (err) { + setError(t('imagesReadFailed')); + } + } + + async function checkSlugAvailability() { + const value = slug.trim().toLowerCase(); + if (!value) { + setSlugStatus('idle'); + return; + } + setSlugStatus('checking'); + try { + const res = await fetch(`/api/listings/check-slug?slug=${encodeURIComponent(value)}`, { cache: 'no-store' }); + const data = await res.json(); + if (!res.ok || typeof data.available !== 'boolean') { + throw new Error('bad response'); + } + setSlugStatus(data.available ? 'available' : 'taken'); + } catch (err) { + setSlugStatus('error'); + } + } + + async function autoTranslate() { + if (aiLoading) return; + setMessage(null); + setError(null); + setAiLoading(true); + try { + const res = await fetch('/api/listings/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ translations, currentLocale }), + }); + const data = await res.json(); + if (!res.ok || !data.translations) { + throw new Error('bad response'); + } + const incoming = data.translations as Record; + setTranslations((prev) => { + const next = { ...prev }; + SUPPORTED_LOCALES.forEach((loc) => { + const found = incoming[loc]; + if (found) { + next[loc] = { + title: found.title ?? prev[loc].title, + description: found.description ?? prev[loc].description, + teaser: found.teaser ?? prev[loc].teaser, + }; + setSuggestedSlugs((s) => ({ ...s, [loc]: found.slug || s[loc] || slug })); + } + }); + return next; + }); + setMessage(t('aiAutoTranslate')); + } catch (err) { + setError(t('aiApplyError')); + } finally { + setAiLoading(false); + } + } + + function applyAiResponse() { + try { + const parsed = JSON.parse(aiResponse); + if (!parsed?.locales) throw new Error('missing locales'); + setTranslations((prev) => { + const next = { ...prev }; + SUPPORTED_LOCALES.forEach((loc) => { + const incoming = parsed.locales[loc]; + if (incoming) { + next[loc] = { + title: incoming.title ?? prev[loc].title, + description: incoming.description ?? prev[loc].description, + teaser: incoming.teaser ?? prev[loc].teaser, + }; + if (incoming.slug) { + setSuggestedSlugs((s) => ({ ...s, [loc]: incoming.slug })); + } + } + }); + return next; + }); + setMessage(t('aiApplySuccess')); + } catch (err) { + setError(t('aiApplyError')); + } + } + + async function copyAiPrompt() { + try { + if (!navigator?.clipboard) throw new Error('clipboard unavailable'); + await navigator.clipboard.writeText(aiPrompt); + setCopyStatus('copied'); + setTimeout(() => setCopyStatus('idle'), 1500); + } catch (err) { + setCopyStatus('error'); + setTimeout(() => setCopyStatus('idle'), 2000); + } + } + + function buildTranslationEntries() { + return SUPPORTED_LOCALES.map((loc) => ({ + locale: loc, + title: translations[loc].title.trim(), + description: translations[loc].description.trim(), + teaser: translations[loc].teaser.trim(), + slug: (suggestedSlugs[loc] || slug).trim().toLowerCase(), + })).filter((t) => t.title && t.description); + } + + async function submitListing(saveDraft: boolean, e?: React.FormEvent) { + if (e) e.preventDefault(); + setMessage(null); + setError(null); + setLoading(true); + try { + const translationEntries = buildTranslationEntries(); + + const missing: string[] = []; + if (!slug.trim()) missing.push(t('slugLabel')); + if (!saveDraft && translationEntries.length === 0) missing.push(t('translationMissing')); + 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 && !streetAddress.trim()) missing.push(t('streetAddressLabel')); + if (!saveDraft && !contactName.trim()) missing.push(t('contactNameLabel')); + if (!saveDraft && !contactEmail.trim()) missing.push(t('contactEmailLabel')); + if (missing.length) { + setError(t('missingFields', { fields: missing.join(', ') })); + setLoading(false); + return; + } + + const res = await fetch(`/api/listings/${params.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + saveDraft, + slug, + translations: translationEntries.map((t) => ({ + ...t, + teaser: t.teaser || null, + })), + country, + region, + city, + streetAddress, + addressNote, + latitude: latitude === '' ? null : latitude, + longitude: longitude === '' ? null : longitude, + contactName, + contactEmail, + maxGuests, + bedrooms, + beds, + bathrooms, + priceWeekdayEuros: priceWeekday === '' ? null : Math.round(Number(priceWeekday)), + priceWeekendEuros: priceWeekend === '' ? null : Math.round(Number(priceWeekend)), + hasSauna, + hasFireplace, + hasWifi, + petsAllowed, + byTheLake, + hasAirConditioning, + hasKitchen, + hasDishwasher, + hasWashingMachine, + hasBarbecue, + hasMicrowave, + hasFreeParking, + evChargingAvailable, + coverImageIndex, + images: selectedImages.length ? parseImages() : undefined, + calendarUrls, + }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Failed to update listing'); + } else { + setMessage(saveDraft ? t('createListingSuccess', { id: data.listing.id, status: 'DRAFT' }) : t('createListingSuccess', { id: data.listing.id, status: data.listing.status })); + setInitialStatus(data.listing.status); + } + } catch (err) { + setError('Failed to update listing'); + } finally { + setLoading(false); + } + } + + if (loadingListing) { + return ( +
+

{t('createListingTitle')}

+

{t('loading')}

+
+ ); + } + + return ( +
+
+

{t('createListingTitle')}

+ + {t('myListingsTitle')} + +
+ {initialStatus && initialStatus !== ListingStatus.PUBLISHED ? ( +
+ {t('statusLabel')}: {initialStatus} +
+ ) : null} +
submitListing(false, e)} style={{ display: 'grid', gap: 10 }}> +
+
+ {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 ( + + ); + })} +
+
+
+
+

{t('localeSectionTitle')}

+ +