lomavuokraus/app/listings/new/page.tsx
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

1181 lines
36 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "../../components/I18nProvider";
import type { Locale } from "../../../lib/i18n";
type ImageInput = { data: string; mimeType: string; altText?: string };
type SelectedImage = {
name: string;
size: number;
mimeType: string;
dataUrl: string;
};
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 NewListingPage() {
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 [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(4);
const [bedrooms, setBedrooms] = useState(2);
const [beds, setBeds] = useState(3);
const [bathrooms, setBathrooms] = useState(1);
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 [isAuthed, setIsAuthed] = useState(false);
const [aiResponse, setAiResponse] = useState("");
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">(
"idle",
);
const [aiLoading, setAiLoading] = useState(false);
const [showManualAi, setShowManualAi] = useState(false);
const [slugStatus, setSlugStatus] = useState<
"idle" | "checking" | "available" | "taken" | "error"
>("idle");
const [suggestedSlugs, setSuggestedSlugs] = useState<Record<Locale, string>>({
en: "",
fi: "",
sv: "",
});
useEffect(() => {
setCurrentLocale(uiLocale as Locale);
}, [uiLocale]);
useEffect(() => {
// simple check if session exists
fetch("/api/auth/me", { cache: "no-store" })
.then((res) => res.json())
.then((data) => setIsAuthed(Boolean(data.user)))
.catch(() => setIsAuthed(false));
}, []);
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),
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
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,
},
];
function updateTranslation(
locale: Locale,
field: keyof LocaleFields,
value: string,
) {
setTranslations((prev) => ({
...prev,
[locale]: { ...prev[locale], [field]: value },
}));
}
function localeStatus(locale: Locale) {
const { title, description } = translations[locale];
if (!title && !description) return "missing";
if (title && description) return "ready";
return "partial";
}
const aiPrompt = useMemo(() => {
const payload = {
task: "Translate this localization file for a holiday rental listing.",
instructions: [
"Preserve meaning, tone, numbers, and any markup.",
"Return valid JSON only with the same keys.",
"Fill missing translations; keep existing text unchanged.",
"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 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 aiLoc = incoming?.[loc];
next[loc] = {
title: prev[loc].title || aiLoc?.title || "",
teaser: prev[loc].teaser || aiLoc?.teaser || "",
description: prev[loc].description || aiLoc?.description || "",
};
});
return next;
});
setSuggestedSlugs((prev) => {
const next = { ...prev };
SUPPORTED_LOCALES.forEach((loc) => {
if (incoming?.[loc]?.slug) {
next[loc] = incoming[loc].slug as string;
}
});
return next;
});
setMessage(t("aiAutoSuccess"));
setShowManualAi(false);
} catch (err) {
setError(t("aiAutoError"));
setShowManualAi(true);
} finally {
setAiLoading(false);
}
}
function applyAiResponse() {
setMessage(null);
setError(null);
try {
const parsed = JSON.parse(aiResponse);
const locales = parsed?.locales || parsed;
if (!locales || typeof locales !== "object") {
throw new Error("Missing locales");
}
const next = { ...translations };
SUPPORTED_LOCALES.forEach((loc) => {
if (locales[loc]) {
const incoming = locales[loc];
next[loc] = {
title:
typeof incoming.title === "string" && incoming.title
? incoming.title
: next[loc].title,
teaser:
typeof incoming.teaser === "string" && incoming.teaser
? incoming.teaser
: next[loc].teaser,
description:
typeof incoming.description === "string" && incoming.description
? incoming.description
: next[loc].description,
};
}
});
setSuggestedSlugs((prev) => {
const next = { ...prev };
SUPPORTED_LOCALES.forEach((loc) => {
const incoming = locales[loc];
if (incoming && typeof incoming.slug === "string" && incoming.slug) {
next[loc] = incoming.slug;
}
});
return next;
});
setTranslations(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 parseImages(): ImageInput[] {
return selectedImages.map((img) => ({
data: img.dataUrl,
mimeType: img.mimeType,
altText: img.name.replace(/[-_]/g, " "),
}));
}
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 submitListing(saveDraft: boolean, e?: React.FormEvent) {
if (e) e.preventDefault();
setMessage(null);
setError(null);
setLoading(true);
try {
const translationEntries = SUPPORTED_LOCALES.map((loc) => ({
locale: loc,
title: translations[loc].title.trim(),
description: translations[loc].description.trim(),
teaser: translations[loc].teaser.trim(),
})).filter((t) => t.title && t.description);
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 (!saveDraft && selectedImages.length === 0)
missing.push(t("imagesLabel"));
if (missing.length) {
setError(t("missingFields", { fields: missing.join(", ") }));
setLoading(false);
return;
}
const res = await fetch("/api/listings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
saveDraft,
slug,
translations: translationEntries.map((t) => ({
...t,
slug,
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: parseImages(),
calendarUrls,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to create listing");
} else {
setMessage(
saveDraft
? t("createListingSuccess", {
id: data.listing.id,
status: "DRAFT",
})
: t("createListingSuccess", {
id: data.listing.id,
status: data.listing.status,
}),
);
if (!saveDraft) {
setSlug("");
setTranslations({
en: { title: "", description: "", teaser: "" },
fi: { title: "", description: "", teaser: "" },
sv: { title: "", description: "", teaser: "" },
});
setMaxGuests(4);
setBedrooms(2);
setBeds(3);
setBathrooms(1);
setPriceWeekday("");
setPriceWeekend("");
setHasSauna(true);
setHasFireplace(true);
setHasWifi(true);
setPetsAllowed(false);
setByTheLake(false);
setHasAirConditioning(false);
setHasKitchen(true);
setHasDishwasher(false);
setHasWashingMachine(false);
setHasBarbecue(false);
setHasMicrowave(false);
setHasFreeParking(false);
setHasSkiPass(false);
setEvChargingAvailable(false);
setEvChargingOnSite(false);
setWheelchairAccessible(false);
setRegion("");
setCity("");
setStreetAddress("");
setAddressNote("");
setLatitude("");
setLongitude("");
setContactName("");
setContactEmail("");
setCalendarUrls("");
setSelectedImages([]);
setCoverImageIndex(1);
}
}
} catch (err) {
setError("Failed to create listing");
} finally {
setLoading(false);
}
}
if (!isAuthed) {
return (
<main className="panel" style={{ maxWidth: 720, margin: "40px auto" }}>
<h1>{t("createListingTitle")}</h1>
<p>{t("loginToCreate")}</p>
</main>
);
}
return (
<main className="panel" style={{ maxWidth: 1100, margin: "40px auto" }}>
<h1>{t("createListingTitle")}</h1>
<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) =>
updateTranslation(currentLocale, "title", e.target.value)
}
/>
</label>
<label>
{t("descriptionLabel")}
<textarea
value={translations[currentLocale].description}
onChange={(e) =>
updateTranslation(
currentLocale,
"description",
e.target.value,
)
}
rows={6}
/>
</label>
<label>
{t("teaserLabel")}
<input
value={translations[currentLocale].teaser}
onChange={(e) =>
updateTranslation(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(180px, 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)}
placeholder="https://example.com/calendar.ics"
rows={3}
/>
<div style={{ color: "#cbd5e1", fontSize: 12 }}>
{t("calendarUrlsHelp")}
</div>
</label>
<div
style={{
display: "grid",
gap: 8,
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 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="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
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>
) : null}
<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>
);
}