1339 lines
42 KiB
TypeScript
1339 lines
42 KiB
TypeScript
"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 = {
|
|
id?: string;
|
|
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 [evChargingOnSite, setEvChargingOnSite] = useState(false);
|
|
const [wheelchairAccessible, setWheelchairAccessible] = useState(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: Record<Locale, LocaleFields> = {
|
|
en: { title: "", description: "", teaser: "" },
|
|
fi: { title: "", description: "", teaser: "" },
|
|
sv: { title: "", description: "", teaser: "" },
|
|
};
|
|
const slugMap: Record<Locale, string> = { en: "", fi: "", sv: "" };
|
|
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);
|
|
setEvChargingOnSite(Boolean(listing.evChargingOnSite));
|
|
setWheelchairAccessible(Boolean(listing.wheelchairAccessible));
|
|
setCalendarUrls((listing.calendarUrls || []).join("\n"));
|
|
if (listing.images?.length) {
|
|
const coverIdx =
|
|
listing.images.find((img: any) => img.isCover)?.order ?? 1;
|
|
setCoverImageIndex(coverIdx);
|
|
setSelectedImages(
|
|
listing.images.map((img: any) => ({
|
|
id: img.id,
|
|
name: img.altText || img.url || `image-${img.id}`,
|
|
size: img.size || 0,
|
|
mimeType: img.mimeType || "image/jpeg",
|
|
dataUrl: img.url || `/api/images/${img.id}`,
|
|
isExisting: true,
|
|
})),
|
|
);
|
|
}
|
|
} 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 removeImageAt(index: number) {
|
|
const img = selectedImages[index];
|
|
setError(null);
|
|
setMessage(null);
|
|
const remainingExisting = selectedImages.filter((i) => i.isExisting).length;
|
|
const hasNewImages = selectedImages.some(
|
|
(i, idx) => idx !== index && !i.isExisting,
|
|
);
|
|
|
|
if (img?.isExisting && remainingExisting <= 1 && !hasNewImages) {
|
|
setError(t("imageRemoveLastError"));
|
|
return;
|
|
}
|
|
|
|
if (img?.isExisting && img.id && remainingExisting > 1) {
|
|
try {
|
|
const res = await fetch(`/api/listings/${params.id}/images/${img.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(data.error || t("imageRemoveFailed"));
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
setError(t("imageRemoveFailed"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const next = selectedImages.filter((_, idx) => idx !== index);
|
|
setSelectedImages(next);
|
|
setCoverImageIndex(
|
|
next.length ? Math.min(coverImageIndex, next.length) : 1,
|
|
);
|
|
}
|
|
|
|
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"));
|
|
}
|
|
}
|
|
|
|
function toggleEvChargingNearby(next: boolean) {
|
|
setEvChargingAvailable(next);
|
|
if (!next) {
|
|
setEvChargingOnSite(false);
|
|
}
|
|
}
|
|
|
|
function toggleEvChargingOnSite(next: boolean) {
|
|
setEvChargingOnSite(next);
|
|
if (next) {
|
|
setEvChargingAvailable(true);
|
|
}
|
|
}
|
|
|
|
const amenityOptions = [
|
|
{
|
|
key: "sauna",
|
|
label: t("amenitySauna"),
|
|
icon: "🧖",
|
|
checked: hasSauna,
|
|
toggle: setHasSauna,
|
|
},
|
|
{
|
|
key: "fireplace",
|
|
label: t("amenityFireplace"),
|
|
icon: "🔥",
|
|
checked: hasFireplace,
|
|
toggle: setHasFireplace,
|
|
},
|
|
{
|
|
key: "wifi",
|
|
label: t("amenityWifi"),
|
|
icon: "📶",
|
|
checked: hasWifi,
|
|
toggle: setHasWifi,
|
|
},
|
|
{
|
|
key: "pets",
|
|
label: t("amenityPets"),
|
|
icon: "🐾",
|
|
checked: petsAllowed,
|
|
toggle: setPetsAllowed,
|
|
},
|
|
{
|
|
key: "lake",
|
|
label: t("amenityLake"),
|
|
icon: "🌊",
|
|
checked: byTheLake,
|
|
toggle: setByTheLake,
|
|
},
|
|
{
|
|
key: "ac",
|
|
label: t("amenityAirConditioning"),
|
|
icon: "❄️",
|
|
checked: hasAirConditioning,
|
|
toggle: setHasAirConditioning,
|
|
},
|
|
{
|
|
key: "kitchen",
|
|
label: t("amenityKitchen"),
|
|
icon: "🍽️",
|
|
checked: hasKitchen,
|
|
toggle: setHasKitchen,
|
|
},
|
|
{
|
|
key: "dishwasher",
|
|
label: t("amenityDishwasher"),
|
|
icon: "🧼",
|
|
checked: hasDishwasher,
|
|
toggle: setHasDishwasher,
|
|
},
|
|
{
|
|
key: "washer",
|
|
label: t("amenityWashingMachine"),
|
|
icon: "🧺",
|
|
checked: hasWashingMachine,
|
|
toggle: setHasWashingMachine,
|
|
},
|
|
{
|
|
key: "barbecue",
|
|
label: t("amenityBarbecue"),
|
|
icon: "🍖",
|
|
checked: hasBarbecue,
|
|
toggle: setHasBarbecue,
|
|
},
|
|
{
|
|
key: "microwave",
|
|
label: t("amenityMicrowave"),
|
|
icon: "🍲",
|
|
checked: hasMicrowave,
|
|
toggle: setHasMicrowave,
|
|
},
|
|
{
|
|
key: "parking",
|
|
label: t("amenityFreeParking"),
|
|
icon: "🅿️",
|
|
checked: hasFreeParking,
|
|
toggle: setHasFreeParking,
|
|
},
|
|
{
|
|
key: "ski",
|
|
label: t("amenitySkiPass"),
|
|
icon: "⛷️",
|
|
checked: hasSkiPass,
|
|
toggle: setHasSkiPass,
|
|
},
|
|
{
|
|
key: "ev",
|
|
label: t("amenityEvNearby"),
|
|
icon: "⚡",
|
|
checked: evChargingAvailable,
|
|
toggle: toggleEvChargingNearby,
|
|
},
|
|
{
|
|
key: "ev-onsite",
|
|
label: t("amenityEvOnSite"),
|
|
icon: "🔌",
|
|
checked: evChargingOnSite,
|
|
toggle: toggleEvChargingOnSite,
|
|
},
|
|
{
|
|
key: "accessible",
|
|
label: t("amenityWheelchairAccessible"),
|
|
icon: "♿",
|
|
checked: wheelchairAccessible,
|
|
toggle: setWheelchairAccessible,
|
|
},
|
|
];
|
|
|
|
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,
|
|
hasSkiPass,
|
|
evChargingAvailable,
|
|
evChargingOnSite,
|
|
wheelchairAccessible,
|
|
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),
|
|
)
|
|
}
|
|
min={0}
|
|
step="10"
|
|
placeholder="120"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{t("priceWeekendLabel")}
|
|
<input
|
|
type="number"
|
|
value={priceWeekend}
|
|
onChange={(e) =>
|
|
setPriceWeekend(
|
|
e.target.value === "" ? "" : Number(e.target.value),
|
|
)
|
|
}
|
|
min={0}
|
|
step="10"
|
|
placeholder="140"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
|
{t("priceHintHelp")}
|
|
</div>
|
|
<label style={{ gridColumn: "1 / -1" }}>
|
|
{t("calendarUrlsLabel")}
|
|
<textarea
|
|
value={calendarUrls}
|
|
onChange={(e) => setCalendarUrls(e.target.value)}
|
|
rows={3}
|
|
placeholder="https://example.com/calendar.ics"
|
|
/>
|
|
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
|
|
{t("calendarUrlsHelp")}
|
|
</div>
|
|
</label>
|
|
<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: 10,
|
|
border: "1px solid rgba(148,163,184,0.3)",
|
|
background: "rgba(255,255,255,0.02)",
|
|
}}
|
|
>
|
|
<h3 style={{ margin: 0 }}>{t("listingAmenities")}</h3>
|
|
<div className="amenity-grid">
|
|
{amenityOptions.map((option) => (
|
|
<button
|
|
key={option.key}
|
|
type="button"
|
|
className={`amenity-option ${option.checked ? "selected" : ""}`}
|
|
aria-pressed={option.checked}
|
|
onClick={() => option.toggle(!option.checked)}
|
|
>
|
|
<div className="amenity-option-meta">
|
|
<span aria-hidden className="amenity-emoji">
|
|
{option.icon}
|
|
</span>
|
|
<span className="amenity-name">{option.label}</span>
|
|
</div>
|
|
<span className="amenity-check" aria-hidden>
|
|
{option.checked ? "✓" : ""}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</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>
|
|
{img.isExisting ? (
|
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
|
{t("existingImageLabel")}
|
|
</div>
|
|
) : null}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
marginTop: 8,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
onClick={() => setCoverImageIndex(idx + 1)}
|
|
>
|
|
{t("makeCover")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
onClick={() => removeImageAt(idx)}
|
|
>
|
|
{t("remove")}
|
|
</button>
|
|
</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: "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>
|
|
);
|
|
}
|