Files
spreewaldzeit/components/admin/ApartmentEditor.tsx

322 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Input, Label, Textarea, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { formatPrice } from "@/lib/utils";
export interface EditorApartment {
id: string;
slug: string;
name: string;
tagline: string;
shortDescription: string;
description: string;
priceFrom: number; // Cent
maxGuests: number;
bedrooms: number;
sizeSqm: number;
features: string[];
images: string[];
airbnbUrl: string | null;
bookingUrl: string | null;
published: boolean;
}
export function ApartmentEditor({ apartment }: { apartment: EditorApartment }) {
const router = useRouter();
const [form, setForm] = useState(apartment);
const [priceEuro, setPriceEuro] = useState(String(Math.round(apartment.priceFrom / 100)));
const [airbnbUrl, setAirbnbUrl] = useState(apartment.airbnbUrl ?? "");
const [bookingUrl, setBookingUrl] = useState(apartment.bookingUrl ?? "");
const [featureInput, setFeatureInput] = useState("");
const [imageInput, setImageInput] = useState("");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function update<K extends keyof EditorApartment>(key: K, value: EditorApartment[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function addFeature() {
const v = featureInput.trim();
if (!v) return;
update("features", [...form.features, v]);
setFeatureInput("");
}
function removeFeature(i: number) {
update("features", form.features.filter((_, idx) => idx !== i));
}
function addImage() {
const v = imageInput.trim();
if (!v) return;
try {
new URL(v);
} catch {
setMessage({ kind: "err", text: "Bild-URL ist ungültig." });
return;
}
update("images", [...form.images, v]);
setImageInput("");
setMessage(null);
}
function removeImage(i: number) {
update("images", form.images.filter((_, idx) => idx !== i));
}
async function save() {
setSaving(true);
setMessage(null);
try {
const res = await fetch(`/api/admin/apartments/${form.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
tagline: form.tagline,
shortDescription: form.shortDescription,
description: form.description,
priceFrom: Math.max(0, Math.round(Number(priceEuro) * 100)),
maxGuests: Number(form.maxGuests),
bedrooms: Number(form.bedrooms),
sizeSqm: Number(form.sizeSqm),
features: form.features,
images: form.images,
airbnbUrl: airbnbUrl.trim() || "",
bookingUrl: bookingUrl.trim() || "",
published: form.published,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Speichern fehlgeschlagen.");
}
setMessage({ kind: "ok", text: "Gespeichert." });
router.refresh();
} catch (err) {
setMessage({
kind: "err",
text: err instanceof Error ? err.message : "Unbekannter Fehler.",
});
} finally {
setSaving(false);
}
}
return (
<div className="bg-cream border border-ink/10 rounded-sm p-6 md:p-8">
<div className="flex items-start justify-between gap-4 mb-8">
<div>
<div className="eyebrow mb-1">/{form.slug}</div>
<h2 className="font-display text-3xl leading-tight">{form.name}</h2>
<div className="mt-2 text-sm text-ink/60">
ab {formatPrice(form.priceFrom)} · bis {form.maxGuests} Gäste · {form.sizeSqm} m²
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer shrink-0">
<input
type="checkbox"
checked={form.published}
onChange={(e) => update("published", e.target.checked)}
className="accent-moss-600 h-4 w-4"
/>
Veröffentlicht
</label>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<Label>Name</Label>
<Input value={form.name} onChange={(e) => update("name", e.target.value)} />
</div>
<div>
<Label>Kurztitel / Tagline</Label>
<Input value={form.tagline} onChange={(e) => update("tagline", e.target.value)} />
</div>
<div className="md:col-span-2">
<Label>Kurzbeschreibung</Label>
<Textarea
rows={2}
value={form.shortDescription}
onChange={(e) => update("shortDescription", e.target.value)}
/>
</div>
<div className="md:col-span-2">
<Label>Beschreibung</Label>
<Textarea
rows={6}
value={form.description}
onChange={(e) => update("description", e.target.value)}
/>
</div>
<div>
<Label>Preis ab (/Nacht)</Label>
<Input
type="number"
min={0}
value={priceEuro}
onChange={(e) => setPriceEuro(e.target.value)}
/>
</div>
<div>
<Label>Max. Gäste</Label>
<Input
type="number"
min={1}
value={form.maxGuests}
onChange={(e) => update("maxGuests", Number(e.target.value))}
/>
</div>
<div>
<Label>Schlafzimmer</Label>
<Input
type="number"
min={0}
value={form.bedrooms}
onChange={(e) => update("bedrooms", Number(e.target.value))}
/>
</div>
<div>
<Label>Größe (m²)</Label>
<Input
type="number"
min={1}
value={form.sizeSqm}
onChange={(e) => update("sizeSqm", Number(e.target.value))}
/>
</div>
</div>
{/* Features */}
<div className="mt-10">
<Label>Ausstattung</Label>
<div className="flex flex-wrap gap-2 mt-3 mb-3">
{form.features.map((f, i) => (
<span
key={`${f}-${i}`}
className="inline-flex items-center gap-2 bg-parchment border border-ink/15 px-3 py-1 rounded-full text-sm"
>
{f}
<button
onClick={() => removeFeature(i)}
aria-label="Entfernen"
className="text-ink/40 hover:text-red-700 text-lg leading-none"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="z. B. Kachelofen"
value={featureInput}
onChange={(e) => setFeatureInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addFeature();
}
}}
/>
<Button type="button" variant="outline" onClick={addFeature}>
Hinzufügen
</Button>
</div>
</div>
{/* Bilder */}
<div className="mt-10">
<Label>Bilder (URLs)</Label>
{form.images.length > 0 && (
<ul className="grid grid-cols-3 md:grid-cols-5 gap-3 mt-3 mb-4">
{form.images.map((src, i) => (
<li key={`${src}-${i}`} className="relative aspect-square rounded-sm overflow-hidden bg-ink/5 group">
<Image
src={src}
alt=""
fill
sizes="200px"
className="object-cover"
/>
<button
onClick={() => removeImage(i)}
className="absolute top-1 right-1 bg-ink/80 text-parchment text-xs w-6 h-6 rounded-full opacity-0 group-hover:opacity-100 transition"
aria-label="Bild entfernen"
>
×
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
placeholder="https://…"
value={imageInput}
onChange={(e) => setImageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addImage();
}
}}
/>
<Button type="button" variant="outline" onClick={addImage}>
Hinzufügen
</Button>
</div>
<p className="mt-2 text-xs text-ink/50">
Für den MVP werden Bilder als URLs gepflegt. Später per Upload über S3/Cloudinary erweiterbar.
</p>
</div>
{/* Buchungsplattformen */}
<div className="mt-10">
<Label>Buchungsplattformen</Label>
<p className="mt-1 mb-4 text-xs text-ink/50">
Wenn Airbnb- oder Booking.com-Links gesetzt sind, werden sie auf der Wohnungsseite als externe Buchungsoption angezeigt.
</p>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label>Airbnb-Link</Label>
<Input
placeholder="https://www.airbnb.de/rooms/…"
value={airbnbUrl}
onChange={(e) => setAirbnbUrl(e.target.value)}
/>
</div>
<div>
<Label>Booking.com-Link</Label>
<Input
placeholder="https://www.booking.com/hotel/…"
value={bookingUrl}
onChange={(e) => setBookingUrl(e.target.value)}
/>
</div>
</div>
</div>
{/* Aktionen */}
<div className="mt-10 pt-6 border-t border-ink/10 flex items-center justify-between gap-4 flex-wrap">
<div className="text-sm">
{message && (
<span className={message.kind === "ok" ? "text-moss-700" : "text-red-700"}>
{message.text}
</span>
)}
</div>
<Button onClick={save} disabled={saving} size="lg">
{saving ? "Speichert…" : "Änderungen speichern"}
</Button>
</div>
</div>
);
}