Merge pull request 'feature/listing-edit' (#6) from feature/listing-edit into master
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
Reviewed-on: #6
This commit is contained in:
commit
69d5d33530
7 changed files with 1044 additions and 6 deletions
5
.trivyignore
Normal file
5
.trivyignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
creds/**
|
||||
reports/**
|
||||
*.pem
|
||||
*.key
|
||||
*.enc
|
||||
280
app/api/listings/[id]/route.ts
Normal file
280
app/api/listings/[id]/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ function pickTranslation<T extends { locale: string }>(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;
|
||||
|
|
|
|||
731
app/listings/edit/[id]/page.tsx
Normal file
731
app/listings/edit/[id]/page.tsx
Normal file
|
|
@ -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<Locale>(uiLocale as Locale);
|
||||
const [translations, setTranslations] = useState<Record<Locale, LocaleFields>>({
|
||||
en: { title: '', description: '', teaser: '' },
|
||||
fi: { title: '', description: '', teaser: '' },
|
||||
sv: { title: '', description: '', teaser: '' },
|
||||
});
|
||||
const [suggestedSlugs, setSuggestedSlugs] = useState<Record<Locale, string>>({ 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<number | ''>('');
|
||||
const [longitude, setLongitude] = useState<number | ''>('');
|
||||
const [contactName, setContactName] = useState('');
|
||||
const [contactEmail, setContactEmail] = useState('');
|
||||
const [maxGuests, setMaxGuests] = useState<number | ''>('');
|
||||
const [bedrooms, setBedrooms] = useState<number | ''>('');
|
||||
const [beds, setBeds] = useState<number | ''>('');
|
||||
const [bathrooms, setBathrooms] = useState<number | ''>('');
|
||||
const [priceWeekday, setPriceWeekday] = useState<number | ''>('');
|
||||
const [priceWeekend, setPriceWeekend] = useState<number | ''>('');
|
||||
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<boolean>(false);
|
||||
const [calendarUrls, setCalendarUrls] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
|
||||
const [coverImageIndex, setCoverImageIndex] = useState(1);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<ListingStatus | null>(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<Locale, LocaleFields & { slug?: string }>,
|
||||
),
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}, [translations, currentLocale, suggestedSlugs, slug]);
|
||||
|
||||
async function readFileAsDataUrl(file: File): Promise<SelectedImage> {
|
||||
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<HTMLInputElement>) {
|
||||
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<Locale, LocaleFields & { slug?: string }>;
|
||||
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 (
|
||||
<main className="panel" style={{ maxWidth: 720, margin: '40px auto' }}>
|
||||
<h1>{t('createListingTitle')}</h1>
|
||||
<p>{t('loading')}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h1>{t('createListingTitle')}</h1>
|
||||
<Link href="/listings/mine" className="button secondary">
|
||||
{t('myListingsTitle')}
|
||||
</Link>
|
||||
</div>
|
||||
{initialStatus && initialStatus !== ListingStatus.PUBLISHED ? (
|
||||
<div className="badge warning" style={{ display: 'inline-block', marginBottom: 12 }}>
|
||||
{t('statusLabel')}: {initialStatus}
|
||||
</div>
|
||||
) : null}
|
||||
<form onSubmit={(e) => submitListing(false, e)} style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
<strong>{t('languageTabsLabel')}</strong>
|
||||
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('languageTabsHint')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{SUPPORTED_LOCALES.map((loc) => {
|
||||
const status = localeStatus(loc);
|
||||
const badge = status === 'ready' ? t('localeReady') : status === 'partial' ? t('localePartial') : t('localeMissing');
|
||||
return (
|
||||
<button key={loc} type="button" onClick={() => setCurrentLocale(loc)} className={`button ${currentLocale === loc ? '' : 'secondary'}`}>
|
||||
{loc.toUpperCase()} · {badge}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<div className="panel" style={{ display: 'grid', gap: 10, border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||
<h3 style={{ margin: 0 }}>{t('localeSectionTitle')}</h3>
|
||||
<label>
|
||||
{t('titleLabel')}
|
||||
<input value={translations[currentLocale].title} onChange={(e) => setTranslations((prev) => ({ ...prev, [currentLocale]: { ...prev[currentLocale], title: e.target.value } }))} />
|
||||
</label>
|
||||
<label>
|
||||
{t('descriptionLabel')}
|
||||
<textarea value={translations[currentLocale].description} onChange={(e) => setTranslations((prev) => ({ ...prev, [currentLocale]: { ...prev[currentLocale], description: e.target.value } }))} rows={6} />
|
||||
</label>
|
||||
<label>
|
||||
{t('teaserLabel')}
|
||||
<input
|
||||
value={translations[currentLocale].teaser}
|
||||
onChange={(e) => setTranslations((prev) => ({ ...prev, [currentLocale]: { ...prev[currentLocale], teaser: e.target.value } }))}
|
||||
placeholder={t('teaserHelp')}
|
||||
/>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('aiOptionalHint')}</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('slugLabel')}
|
||||
<input
|
||||
value={suggestedSlugs[currentLocale] || slug}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setSlug(val);
|
||||
setSuggestedSlugs((prev) => ({ ...prev, [currentLocale]: val }));
|
||||
}}
|
||||
onBlur={checkSlugAvailability}
|
||||
/>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('slugPreview', { url: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://lomavuokraus.fi'}/${suggestedSlugs[currentLocale] || slug || 'your-slug-here'}` })}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugHelp')}</span>
|
||||
<span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('aiOptionalHint')}</span>
|
||||
{slugStatus === 'checking' ? <span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugChecking')}</span> : null}
|
||||
{slugStatus === 'available' ? <span style={{ color: '#34d399', fontSize: 12 }}>{t('slugAvailable')}</span> : null}
|
||||
{slugStatus === 'taken' ? <span style={{ color: '#f87171', fontSize: 12 }}>{t('slugTaken')}</span> : null}
|
||||
{slugStatus === 'error' ? <span style={{ color: '#facc15', fontSize: 12 }}>{t('slugCheckError')}</span> : null}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel" style={{ border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||
<h3 style={{ marginTop: 0 }}>{t('aiHelperTitle')}</h3>
|
||||
<p style={{ color: '#cbd5e1', marginTop: 4 }}>{t('aiAutoExplain')}</p>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="button secondary" onClick={autoTranslate} disabled={aiLoading}>
|
||||
{aiLoading ? t('aiAutoTranslating') : t('aiAutoTranslate')}
|
||||
</button>
|
||||
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('aiHelperNote')}</span>
|
||||
</div>
|
||||
<details open={showManualAi} style={{ marginTop: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', color: '#cbd5e1' }}>{showManualAi ? t('aiManualLead') : t('aiManualLead')}</summary>
|
||||
<div style={{ marginTop: 8, display: 'grid', gap: 6 }}>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{t('aiPromptLabel')}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{copyStatus === 'copied' ? <span style={{ color: '#34d399', fontSize: 13 }}>{t('aiPromptCopied')}</span> : null}
|
||||
{copyStatus === 'error' ? <span style={{ color: '#f87171', fontSize: 13 }}>{t('aiCopyError')}</span> : null}
|
||||
<button type="button" className="button secondary" onClick={copyAiPrompt} style={{ minHeight: 0, padding: '8px 12px' }}>
|
||||
{t('aiCopyPrompt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={aiPrompt} readOnly rows={10} style={{ fontFamily: 'monospace' }} />
|
||||
</div>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span>{t('aiResponseLabel')}</span>
|
||||
<textarea value={aiResponse} onChange={(e) => setAiResponse(e.target.value)} rows={6} placeholder='{"locales":{"fi":{"title":"..."}}}' style={{ fontFamily: 'monospace' }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="button secondary" onClick={() => applyAiResponse()}>
|
||||
{t('aiApply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
<label>
|
||||
{t('countryLabel')}
|
||||
<input value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('regionLabel')}
|
||||
<input value={region} onChange={(e) => setRegion(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('cityLabel')}
|
||||
<input value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
{t('streetAddressLabel')}
|
||||
<input value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('addressNoteLabel')}
|
||||
<input value={addressNote} onChange={(e) => setAddressNote(e.target.value)} placeholder={t('addressNotePlaceholder')} />
|
||||
</label>
|
||||
<label>
|
||||
{t('contactNameLabel')}
|
||||
<input value={contactName} onChange={(e) => setContactName(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('contactEmailLabel')}
|
||||
<input type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
||||
<label>
|
||||
{t('maxGuestsLabel')}
|
||||
<select value={maxGuests} onChange={(e) => setMaxGuests(Number(e.target.value))}>
|
||||
{Array.from({ length: 30 }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('bedroomsLabel')}
|
||||
<select value={bedrooms} onChange={(e) => setBedrooms(Number(e.target.value))}>
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('bedsLabel')}
|
||||
<select value={beds} onChange={(e) => setBeds(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('bathroomsLabel')}
|
||||
<select value={bathrooms} onChange={(e) => setBathrooms(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
||||
<label>
|
||||
{t('priceWeekdayLabel')}
|
||||
<input type="number" value={priceWeekday} onChange={(e) => setPriceWeekday(e.target.value === '' ? '' : Number(e.target.value))} />
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHint')}</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('priceWeekendLabel')}
|
||||
<input type="number" value={priceWeekend} onChange={(e) => setPriceWeekend(e.target.value === '' ? '' : Number(e.target.value))} />
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHint')}</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
<label>
|
||||
{t('latitudeLabel')}
|
||||
<input type="number" value={latitude} onChange={(e) => setLatitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
|
||||
</label>
|
||||
<label>
|
||||
{t('longitudeLabel')}
|
||||
<input type="number" value={longitude} onChange={(e) => setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel" style={{ display: 'grid', gap: 8, border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||
<h3 style={{ margin: 0 }}>{t('amenities')}</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 8 }}>
|
||||
{[
|
||||
{ label: t('amenitySauna'), state: hasSauna, set: setHasSauna },
|
||||
{ label: t('amenityFireplace'), state: hasFireplace, set: setHasFireplace },
|
||||
{ label: t('amenityWifi'), state: hasWifi, set: setHasWifi },
|
||||
{ label: t('amenityPets'), state: petsAllowed, set: setPetsAllowed },
|
||||
{ label: t('amenityLake'), state: byTheLake, set: setByTheLake },
|
||||
{ label: t('amenityAirConditioning'), state: hasAirConditioning, set: setHasAirConditioning },
|
||||
{ label: t('amenityKitchen'), state: hasKitchen, set: setHasKitchen },
|
||||
{ label: t('amenityDishwasher'), state: hasDishwasher, set: setHasDishwasher },
|
||||
{ label: t('amenityWashingMachine'), state: hasWashingMachine, set: setHasWashingMachine },
|
||||
{ label: t('amenityBarbecue'), state: hasBarbecue, set: setHasBarbecue },
|
||||
{ label: t('amenityMicrowave'), state: hasMicrowave, set: setHasMicrowave },
|
||||
{ label: t('amenityFreeParking'), state: hasFreeParking, set: setHasFreeParking },
|
||||
{ label: t('amenitySkiPass'), state: hasSkiPass, set: setHasSkiPass },
|
||||
{ label: t('amenityEvAvailable'), state: evChargingAvailable, set: setEvChargingAvailable },
|
||||
].map((item) => (
|
||||
<label key={item.label} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={item.state} onChange={(e) => item.set(e.target.checked)} /> {item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
|
||||
<label>
|
||||
{t('imagesLabel')}
|
||||
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('coverImageLabel')}
|
||||
<input type="number" min={1} max={selectedImages.length || 1} value={coverImageIndex} onChange={(e) => setCoverImageIndex(Number(e.target.value))} />
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12, marginTop: 4 }}>{t('coverImageHelp')}</div>
|
||||
</label>
|
||||
</div>
|
||||
{selectedImages.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
{selectedImages.map((img, idx) => (
|
||||
<div key={img.name + idx} style={{ border: '1px solid rgba(148,163,184,0.3)', padding: 8, borderRadius: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>{img.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#cbd5e1' }}>
|
||||
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<label>
|
||||
{t('calendarUrlsLabel')}
|
||||
<textarea
|
||||
value={calendarUrls}
|
||||
onChange={(e) => setCalendarUrls(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="https://example.com/availability.ics"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button className="button secondary" type="button" disabled={loading} onClick={(e) => submitListing(true, e)}>
|
||||
{loading ? t('saving') : t('saveDraft')}
|
||||
</button>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? t('submittingListing') : t('submitListing')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -103,9 +103,14 @@ export default function MyListingsPage() {
|
|||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<Link href={`/listings/edit/${l.id}`} className="button secondary">
|
||||
{l.status === 'DRAFT' ? 'Edit' : t('view')}
|
||||
</Link>
|
||||
{l.status !== 'DRAFT' ? (
|
||||
<Link href={`/listings/${l.translations[0]?.slug ?? ''}`} className="button secondary">
|
||||
{t('view')}
|
||||
</Link>
|
||||
) : null}
|
||||
<button className="button secondary" onClick={() => removeListing(l.id)} disabled={actionId === l.id}>
|
||||
{actionId === l.id ? t('removing') : t('remove')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" "<a href=\"trivy.txt\">report</a>" "report: ${TRIVY_TXT}"
|
||||
else
|
||||
record_result "Trivy (${TRIVY_MODE})" "FAIL" "<a href=\"trivy.txt\">report</a>" "report: ${TRIVY_TXT}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue