Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)

This commit is contained in:
2026-06-03 14:08:48 +02:00
committed by Ihor_Zhekov
commit bf5d79a919
94 changed files with 12480 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
const nav = [
{ href: "/admin/anfragen", label: "Anfragen" },
{ href: "/admin/kalender", label: "Kalender" },
{ href: "/admin/wohnungen", label: "Wohnungen" },
];
export function AdminNav({ email }: { email: string }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch("/api/admin/logout", { method: "POST" });
router.push("/admin/login");
router.refresh();
}
return (
<header className="bg-ink text-parchment">
<div className="container flex flex-col md:flex-row md:items-center justify-between gap-4 py-5">
<div className="flex items-center gap-8">
<Link href="/admin/anfragen" className="font-display text-xl tracking-tight">
Spreewaldzeit{" "}
<span className="text-parchment/50 text-sm ml-1">· Admin</span>
</Link>
<nav className="flex gap-1 md:gap-2">
{nav.map((item) => {
const active = pathname?.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"px-3 py-1.5 rounded-full text-sm transition",
active
? "bg-parchment text-ink"
: "text-parchment/70 hover:text-parchment hover:bg-parchment/10"
)}
>
{item.label}
</Link>
);
})}
</nav>
</div>
<div className="flex items-center gap-4 text-sm">
<Link href="/" target="_blank" className="text-parchment/60 hover:text-parchment">
Website ansehen
</Link>
<span className="hidden md:inline text-parchment/40">·</span>
<span className="hidden md:inline text-parchment/70">{email}</span>
<button
onClick={logout}
className="px-3 py-1.5 rounded-full bg-parchment/10 hover:bg-parchment/20 text-sm transition"
>
Abmelden
</button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,321 @@
"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>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/utils";
import { Input, Label, Textarea, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
interface ApartmentLite {
id: string;
slug: string;
name: string;
}
export interface BlockRow {
id: string;
apartmentId: string;
apartmentName: string;
startDate: string;
endDate: string;
reason: string;
source: string;
note: string | null;
}
export function CalendarManager({
apartments,
blocks,
}: {
apartments: ApartmentLite[];
blocks: BlockRow[];
}) {
const router = useRouter();
const [apartmentId, setApartmentId] = useState(apartments[0]?.id ?? "");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [note, setNote] = useState("");
const [reason, setReason] = useState<"manual" | "maintenance" | "booking">("manual");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// iCal sync state
const [icalApartmentId, setIcalApartmentId] = useState(apartments[0]?.id ?? "");
const [icalUrl, setIcalUrl] = useState("");
const [icalSource, setIcalSource] = useState<"airbnb" | "booking">("airbnb");
const [icalSyncing, setIcalSyncing] = useState(false);
const [icalMessage, setIcalMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await fetch("/api/admin/blocks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apartmentId, startDate, endDate, note, reason }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Konnte Zeitraum nicht sperren.");
}
setStartDate("");
setEndDate("");
setNote("");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Unbekannter Fehler.");
} finally {
setSaving(false);
}
}
async function removeBlock(id: string) {
if (!confirm("Zeitraum freigeben?")) return;
const res = await fetch(`/api/admin/blocks/${id}`, { method: "DELETE" });
if (res.ok) router.refresh();
}
async function syncIcal(e: React.FormEvent) {
e.preventDefault();
if (!icalUrl.trim()) return;
setIcalSyncing(true);
setIcalMessage(null);
try {
const res = await fetch("/api/admin/sync-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apartmentId: icalApartmentId, icalUrl: icalUrl.trim(), source: icalSource }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? "Synchronisation fehlgeschlagen.");
setIcalMessage({
kind: "ok",
text: `${data.imported} Buchungen importiert, ${data.deleted} alte entfernt.`,
});
setIcalUrl("");
router.refresh();
} catch (err) {
setIcalMessage({ kind: "err", text: err instanceof Error ? err.message : "Fehler." });
} finally {
setIcalSyncing(false);
}
}
return (
<div className="grid md:grid-cols-12 gap-10">
{/* Formular */}
<form onSubmit={onSubmit} className="md:col-span-5 bg-cream border border-ink/10 rounded-sm p-6 md:p-8 space-y-6 h-fit">
<div>
<h2 className="font-display text-2xl">Zeitraum sperren</h2>
<p className="text-sm text-ink/60 mt-1">
Für externe Buchungen, eigene Nutzung oder Wartung.
</p>
</div>
<div>
<Label htmlFor="apartment">Wohnung</Label>
<select
id="apartment"
value={apartmentId}
onChange={(e) => setApartmentId(e.target.value)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
{apartments.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start">Von</Label>
<Input id="start" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div>
<Label htmlFor="end">Bis</Label>
<Input id="end" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
</div>
</div>
<div>
<Label htmlFor="reason">Grund</Label>
<select
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value as typeof reason)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
<option value="manual">Manuell gesperrt</option>
<option value="booking">Externe Buchung</option>
<option value="maintenance">Wartung / Pflege</option>
</select>
</div>
<div>
<Label htmlFor="note">Notiz (optional)</Label>
<Textarea id="note" rows={3} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<FieldError message={error ?? undefined} />
<Button type="submit" size="lg" disabled={saving} className="w-full">
{saving ? "Wird gespeichert…" : "Zeitraum sperren"}
</Button>
</form>
{/* Liste */}
<div className="md:col-span-7">
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-2xl">Gesperrte Zeiträume</h2>
<span className="text-xs text-ink/50">{blocks.length} gesamt</span>
</div>
{blocks.length === 0 ? (
<div className="bg-cream border border-ink/10 rounded-sm p-10 text-center text-ink/55">
Keine gesperrten Zeiträume.
</div>
) : (
<ul className="space-y-2">
{blocks.map((b) => (
<li
key={b.id}
className="grid grid-cols-[1fr_auto] items-center gap-4 border border-ink/10 bg-cream rounded-sm px-5 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{b.apartmentName}</span>
<span className="text-xs uppercase tracking-wider text-ink/50 bg-ink/5 px-2 py-0.5 rounded">
{b.reason === "booking"
? "Buchung"
: b.reason === "maintenance"
? "Wartung"
: "Manuell"}
</span>
{b.source !== "manual" && (
<span className="text-xs uppercase tracking-wider text-moss-700 bg-moss-50 px-2 py-0.5 rounded">
{b.source}
</span>
)}
</div>
<div className="text-sm text-ink/70 mt-1">
{formatDate(b.startDate)} {formatDate(b.endDate)}
{b.note && <span className="text-ink/50"> · {b.note}</span>}
</div>
</div>
<button
onClick={() => removeBlock(b.id)}
className="text-sm px-3 py-1.5 rounded-full hover:bg-red-50 text-red-700 shrink-0"
>
Freigeben
</button>
</li>
))}
</ul>
)}
</div>
{/* iCal Sync */}
<div className="md:col-span-12 mt-2">
<div className="bg-moss-50 border border-moss-200 rounded-sm p-6 md:p-8">
<h2 className="font-display text-2xl mb-1">iCal-Synchronisation</h2>
<p className="text-sm text-ink/60 mb-6">
Fügen Sie den iCal-Link Ihres Airbnb- oder Booking.com-Inserats ein. Alle bisherigen
Buchungen dieser Quelle werden ersetzt. Den Link finden Sie in Airbnb unter{" "}
<strong>Kalender Verfügbarkeit exportieren</strong> und in Booking.com unter{" "}
<strong>Kalender iCal-Export</strong>.
</p>
<form onSubmit={syncIcal} className="grid md:grid-cols-12 gap-4 items-end">
<div className="md:col-span-3">
<Label htmlFor="ical-apartment">Wohnung</Label>
<select
id="ical-apartment"
value={icalApartmentId}
onChange={(e) => setIcalApartmentId(e.target.value)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
{apartments.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="ical-source">Plattform</Label>
<select
id="ical-source"
value={icalSource}
onChange={(e) => setIcalSource(e.target.value as "airbnb" | "booking")}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
<option value="airbnb">Airbnb</option>
<option value="booking">Booking.com</option>
</select>
</div>
<div className="md:col-span-5">
<Label htmlFor="ical-url">iCal-URL</Label>
<Input
id="ical-url"
type="url"
placeholder="https://www.airbnb.de/calendar/ical/…"
value={icalUrl}
onChange={(e) => setIcalUrl(e.target.value)}
required
/>
</div>
<div className="md:col-span-2">
<Button type="submit" disabled={icalSyncing} className="w-full">
{icalSyncing ? "Synchronisiert…" : "Jetzt synchronisieren"}
</Button>
</div>
</form>
{icalMessage && (
<p className={`mt-3 text-sm ${icalMessage.kind === "ok" ? "text-moss-700" : "text-red-700"}`}>
{icalMessage.text}
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { formatDate, nightsBetween } from "@/lib/utils";
import type { InquiryStatus } from "@/types";
import { INQUIRY_STATUS_LABELS } from "@/types";
export interface InquiryRowData {
id: string;
apartmentName: string;
arrival: string;
departure: string;
guests: number;
name: string;
email: string;
phone: string | null;
message: string;
status: InquiryStatus;
createdAt: string;
}
const statusClasses: Record<InquiryStatus, string> = {
new: "bg-moss-500 text-parchment",
read: "bg-ink/10 text-ink",
confirmed: "bg-moss-700 text-parchment",
declined: "bg-red-100 text-red-800",
archived: "bg-ink/5 text-ink/60",
};
export function InquiryRow({ inquiry }: { inquiry: InquiryRowData }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<InquiryStatus>(inquiry.status);
const [pending, startTransition] = useTransition();
const [saving, setSaving] = useState(false);
async function updateStatus(next: InquiryStatus) {
setSaving(true);
try {
const res = await fetch(`/api/admin/inquiries/${inquiry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
});
if (!res.ok) throw new Error();
setStatus(next);
startTransition(() => router.refresh());
} catch {
alert("Konnte Status nicht speichern.");
} finally {
setSaving(false);
}
}
async function remove() {
if (!confirm("Anfrage wirklich löschen? Das kann nicht rückgängig gemacht werden.")) return;
const res = await fetch(`/api/admin/inquiries/${inquiry.id}`, { method: "DELETE" });
if (res.ok) router.refresh();
}
const nights = nightsBetween(inquiry.arrival, inquiry.departure);
return (
<li className="border border-ink/10 rounded-sm bg-cream overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full grid md:grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-5 py-4 text-left hover:bg-ink/[0.02] transition"
>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-full font-medium ${statusClasses[status]}`}
>
{INQUIRY_STATUS_LABELS[status]}
</span>
<div className="min-w-0">
<div className="font-medium truncate">{inquiry.name}</div>
<div className="text-xs text-ink/60 truncate">
{inquiry.apartmentName} · {inquiry.guests} Gäste · {nights} Nächte
</div>
</div>
<div className="hidden md:block text-sm text-ink/70">
{formatDate(inquiry.arrival)} {formatDate(inquiry.departure)}
</div>
<div className="hidden md:block text-xs text-ink/50 tabular-nums">
{formatDate(inquiry.createdAt)}
</div>
<span className="text-ink/40" aria-hidden>{open ? "▴" : "▾"}</span>
</button>
{open && (
<div className="border-t border-ink/10 px-5 py-5 bg-parchment/50 grid md:grid-cols-2 gap-6">
<dl className="space-y-3 text-sm">
<div>
<dt className="eyebrow mb-0.5">E-Mail</dt>
<dd>
<a href={`mailto:${inquiry.email}`} className="link-underline">
{inquiry.email}
</a>
</dd>
</div>
{inquiry.phone && (
<div>
<dt className="eyebrow mb-0.5">Telefon</dt>
<dd>{inquiry.phone}</dd>
</div>
)}
<div>
<dt className="eyebrow mb-0.5">Zeitraum</dt>
<dd>
{formatDate(inquiry.arrival)} {formatDate(inquiry.departure)} ({nights} N)
</dd>
</div>
<div>
<dt className="eyebrow mb-0.5">Eingegangen</dt>
<dd>{formatDate(inquiry.createdAt)}</dd>
</div>
</dl>
<div>
<div className="eyebrow mb-2">Nachricht</div>
<p className="whitespace-pre-line text-sm text-ink/85 leading-relaxed">
{inquiry.message || <span className="text-ink/40">(keine Nachricht)</span>}
</p>
</div>
<div className="md:col-span-2 flex flex-wrap items-center justify-between gap-4 pt-4 border-t border-ink/10">
<div className="flex items-center gap-3">
<label className="eyebrow">Status</label>
<select
value={status}
disabled={saving || pending}
onChange={(e) => updateStatus(e.target.value as InquiryStatus)}
className="bg-transparent border-b border-ink/25 focus:border-ink py-1.5 text-sm focus:outline-none"
>
{(Object.keys(INQUIRY_STATUS_LABELS) as InquiryStatus[]).map((s) => (
<option key={s} value={s}>{INQUIRY_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<a
href={`mailto:${inquiry.email}?subject=${encodeURIComponent("Ihre Anfrage — " + inquiry.apartmentName)}`}
className="px-4 py-2 text-sm rounded-full border border-ink/20 hover:bg-ink/5"
>
Antworten
</a>
<button
onClick={remove}
className="px-4 py-2 text-sm rounded-full text-red-700 hover:bg-red-50"
>
Löschen
</button>
</div>
</div>
</div>
)}
</li>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/src/style.css";
import { de } from "date-fns/locale";
interface BlockRange {
from: Date;
to: Date;
}
export function AvailabilityCalendar({ slug }: { slug: string }) {
const [blocks, setBlocks] = useState<BlockRange[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/availability/${slug}`, { cache: "no-store" });
if (!res.ok) throw new Error();
const data = (await res.json()) as { blocks: { start: string; end: string }[] };
if (cancelled) return;
setBlocks(
data.blocks.map((b) => ({
from: new Date(b.start),
to: new Date(b.end),
}))
);
} catch {
if (!cancelled) setBlocks([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [slug]);
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Verfügbarkeit</div>
<h2 className="font-display text-3xl md:text-4xl mb-3 leading-tight">
Wann darf es losgehen?
</h2>
<p className="text-ink/65 max-w-xl mb-8">
Durchgestrichene Tage sind bereits belegt. Die Belegung berücksichtigt
auch Buchungen über Airbnb und Booking.com.
</p>
<div className="bg-cream border border-ink/8 rounded-sm p-4 md:p-8 overflow-x-auto shadow-[0_1px_3px_rgba(28,38,32,0.04),0_4px_16px_rgba(28,38,32,0.05)]">
{loading ? (
<div className="h-[320px] flex items-center justify-center text-ink/40 text-sm">
Belegung wird geladen
</div>
) : (
<DayPicker
mode="range"
locale={de}
numberOfMonths={2}
weekStartsOn={1}
disabled={[{ before: today }, ...blocks]}
fromMonth={today}
showOutsideDays={false}
className="mx-auto"
/>
)}
</div>
<div className="mt-4 flex flex-wrap gap-5 text-xs text-ink/55">
<span className="inline-flex items-center gap-2">
<span className="inline-block h-3 w-3 bg-moss-500 rounded-sm" />
ausgewählter Zeitraum
</span>
<span className="inline-flex items-center gap-2">
<span className="inline-block h-3 w-3 bg-stone-soft rounded-sm" style={{ textDecoration: "line-through" }} />
belegt
</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { de } from "date-fns/locale";
interface Period {
start: string;
end: string;
nights: number;
}
function formatRange(start: string, end: string) {
const s = parseISO(start);
const e = parseISO(end);
return {
startLabel: format(s, "EEE, d. MMM", { locale: de }),
endLabel: format(e, "EEE, d. MMM yyyy", { locale: de }),
};
}
export function AvailablePeriods({ slug }: { slug: string }) {
const [periods, setPeriods] = useState<Period[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(`/api/available-periods/${slug}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setPeriods(data.periods ?? []);
})
.catch(() => {
if (!cancelled) setPeriods([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [slug]);
if (loading) {
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<div className="h-40 flex items-center justify-center text-ink/40 text-sm">
Wird geladen
</div>
</section>
);
}
if (periods.length === 0) {
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<p className="text-ink/60 text-sm">
Im Moment keine vorberechneten Zeiträume verfügbar. Bitte direkt anfragen.
</p>
</section>
);
}
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<h2 className="font-display text-3xl md:text-4xl mb-3 leading-tight">
Diese Zeiträume sind noch frei.
</h2>
<p className="text-ink/65 max-w-xl mb-8">
Alle Vorschläge sind 7 Nächte klicken Sie auf einen, um das Anfrageformular direkt
mit diesem Zeitraum zu befüllen.
</p>
<ul className="grid sm:grid-cols-2 gap-3">
{periods.map((p) => {
const { startLabel, endLabel } = formatRange(p.start, p.end);
const href = `/anfrage?wohnung=${slug}&arrival=${p.start}&departure=${p.end}`;
return (
<li key={p.start}>
<Link
href={href}
className="group flex items-center justify-between border border-ink/12 bg-cream/60 hover:bg-cream hover:border-ink/25 hover:shadow-card rounded-sm px-5 py-4 transition-all duration-200"
>
<div>
<div className="font-display text-xl leading-tight">
{startLabel}
<span className="text-ink/35 mx-2 font-sans text-base"></span>
{endLabel}
</div>
<div className="text-xs text-ink/50 mt-1">{p.nights} Nächte</div>
</div>
<span className="text-xs text-moss-600 font-medium opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0 transition-all duration-200 shrink-0 ml-4">
Anfragen
</span>
</Link>
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
interface Props {
airbnbUrl?: string | null;
bookingUrl?: string | null;
className?: string;
}
function AirbnbLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M11.996 0C5.372 0 0 5.372 0 12s5.372 12 11.996 12C18.627 24 24 18.628 24 12S18.627 0 11.996 0zm.004 4.29c.907 0 1.642.735 1.642 1.641 0 .907-.735 1.643-1.642 1.643-.906 0-1.641-.736-1.641-1.643 0-.906.735-1.641 1.641-1.641zm4.372 11.653c-.195.43-.482.808-.84 1.1-.36.29-.784.492-1.236.584-.227.047-.458.07-.69.07-.43 0-.857-.083-1.26-.243l-.346-.134-.346.134c-.403.16-.83.243-1.26.243-.232 0-.463-.023-.69-.07-.452-.092-.876-.294-1.236-.584-.358-.292-.645-.67-.84-1.1-.474-1.054-.192-2.237.28-3.337l2.092-4.738c.09-.203.29-.335.517-.335h.966c.227 0 .427.132.517.335l2.092 4.738c.472 1.1.754 2.283.28 3.337z" />
</svg>
);
}
function BookingLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M19.5 0h-15A4.5 4.5 0 0 0 0 4.5v15A4.5 4.5 0 0 0 4.5 24h15a4.5 4.5 0 0 0 4.5-4.5v-15A4.5 4.5 0 0 0 19.5 0zM9.4 16.9H7V7.1h2.4v9.8zm4.8 0h-2.4V7.1H14c2.2 0 3.6 1.3 3.6 3.3 0 1.4-.7 2.5-1.9 3l2.2 3.5h-2.7l-1.9-3.2h-.1v3.2zm0-5h-.1V9h.2c.9 0 1.4.5 1.4 1.4 0 1-.5 1.5-1.5 1.5z" />
</svg>
);
}
export function BookingPlatforms({ airbnbUrl, bookingUrl, className }: Props) {
if (!airbnbUrl && !bookingUrl) return null;
return (
<div className={cn("space-y-2.5", className)}>
<p className="text-[11px] uppercase tracking-[0.18em] text-ink/45 font-medium mb-3">
Auch buchbar auf
</p>
{airbnbUrl && (
<a
href={airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm hover:border-ink/25 hover:bg-parchment transition-all duration-200 group text-sm"
>
<span className="flex items-center gap-3 text-[#FF5A5F]">
<AirbnbLogo />
<span className="font-medium text-ink">Airbnb</span>
</span>
<span className="text-ink/30 group-hover:text-ink/60 group-hover:translate-x-0.5 transition-all duration-200 text-xs">
</span>
</a>
)}
{bookingUrl && (
<a
href={bookingUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm hover:border-ink/25 hover:bg-parchment transition-all duration-200 group text-sm"
>
<span className="flex items-center gap-3 text-[#003580]">
<BookingLogo />
<span className="font-medium text-ink">Booking.com</span>
</span>
<span className="text-ink/30 group-hover:text-ink/60 group-hover:translate-x-0.5 transition-all duration-200 text-xs">
</span>
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
export function Features({ features }: { features: string[] }) {
if (features.length === 0) return null;
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Ausstattung</div>
<h2 className="font-display text-3xl md:text-4xl mb-8 leading-tight">
Alles da, nichts zu viel.
</h2>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-3">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-3 py-2 border-b border-ink/10 text-ink/80"
>
<span
aria-hidden
className="mt-2 h-1.5 w-1.5 rounded-full bg-moss-500 shrink-0"
/>
<span>{f}</span>
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
const [open, setOpen] = useState<number | null>(null);
const touchStartX = useRef<number | null>(null);
const thumbsRef = useRef<HTMLDivElement>(null);
// Keyboard + scroll lock
useEffect(() => {
if (open === null) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
if (e.key === "ArrowRight") setOpen((i) => (i === null ? null : (i + 1) % images.length));
if (e.key === "ArrowLeft") setOpen((i) => (i === null ? null : (i - 1 + images.length) % images.length));
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, images.length]);
// Scroll active thumbnail into view
useEffect(() => {
if (open === null || !thumbsRef.current) return;
const btn = thumbsRef.current.children[open] as HTMLElement | undefined;
btn?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
}, [open]);
if (images.length === 0) return null;
const [hero, ...rest] = images;
const go = (dir: 1 | -1) =>
setOpen((i) => (i === null ? null : (i + dir + images.length) % images.length));
return (
<>
{/* Grid */}
<div className="grid md:grid-cols-5 gap-2 md:gap-3">
<button
onClick={() => setOpen(0)}
className="md:col-span-3 relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={hero}
alt={`${alt} — Ansicht 1`}
fill
priority
sizes="(max-width: 768px) 100vw, 60vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span className="bg-ink/70 text-parchment text-xs px-3 py-1.5 rounded-full backdrop-blur-sm">
Alle {images.length} Fotos
</span>
</div>
</button>
<div className="md:col-span-2 grid grid-cols-2 gap-2 md:gap-3">
{rest.slice(0, 4).map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx + 1)}
className="relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={src}
alt={`${alt} — Ansicht ${idx + 2}`}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
{idx === 3 && images.length > 5 && (
<div className="absolute inset-0 bg-ink/55 text-parchment flex items-center justify-center text-sm font-medium">
+{images.length - 5}
</div>
)}
</button>
))}
</div>
</div>
{/* Lightbox */}
{open !== null && (
<div
className="fixed inset-0 z-50 bg-ink/95 flex flex-col"
onClick={() => setOpen(null)}
role="dialog"
aria-modal="true"
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }}
onTouchEnd={(e) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - (touchStartX.current as number);
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
touchStartX.current = null;
}}
>
{/* Top bar */}
<div className="shrink-0 flex items-center justify-between px-5 py-4" onClick={(e) => e.stopPropagation()}>
<span className="text-parchment/50 text-sm tabular-nums">{open + 1} / {images.length}</span>
<button
onClick={() => setOpen(null)}
className="text-parchment/70 hover:text-parchment transition-colors text-xl leading-none px-2 py-1"
aria-label="Galerie schließen"
>
</button>
</div>
{/* Image */}
<div
className="flex-1 flex items-center justify-center relative px-14 md:px-20 min-h-0"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={(e) => { e.stopPropagation(); go(-1); }}
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Vorheriges Bild"
></button>
<div className="relative w-full max-w-5xl aspect-[4/3]">
<Image
key={open}
src={images[open]}
alt={`${alt} — Ansicht ${open + 1}`}
fill
sizes="90vw"
className="object-contain"
/>
</div>
<button
onClick={(e) => { e.stopPropagation(); go(1); }}
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Nächstes Bild"
></button>
</div>
{/* Thumbnail strip */}
<div
className="shrink-0 py-3 px-4"
onClick={(e) => e.stopPropagation()}
>
<div ref={thumbsRef} className="flex gap-2 overflow-x-auto" style={{ scrollbarWidth: "none" }}>
{images.map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx)}
className={`relative shrink-0 w-14 h-10 rounded overflow-hidden transition-all duration-200 ${
idx === open ? "ring-2 ring-parchment opacity-100" : "opacity-35 hover:opacity-65"
}`}
>
<Image src={src} alt="" fill sizes="56px" className="object-cover" />
</button>
))}
</div>
</div>
</div>
)}
</>
);
}

53
components/home/About.tsx Normal file
View File

@@ -0,0 +1,53 @@
export function About() {
return (
<section className="py-20 md:py-28 border-t border-ink/10">
<div className="container">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 md:gap-0 md:divide-x divide-ink/10 border border-ink/10 rounded-sm mb-16 md:mb-20">
{[
{ value: "2", label: "Ferienwohnungen" },
{ value: "400 km", label: "Radwege ringsum" },
{ value: "90 min", label: "ab Berlin" },
].map((s) => (
<div key={s.label} className="px-4 py-5 md:px-10 md:py-7 text-center">
<div className="font-display text-2xl md:text-4xl leading-none text-moss-700 mb-1">{s.value}</div>
<div className="text-[10px] md:text-xs text-ink/50 uppercase tracking-[0.12em] md:tracking-[0.15em]">{s.label}</div>
</div>
))}
</div>
{/* Editorial text block */}
<div className="grid md:grid-cols-12 gap-10 md:gap-16">
<div className="md:col-span-4">
<div className="eyebrow mb-4">Über uns</div>
<h2 className="font-display text-display-md">
Zwei Häuser,<br />
<span className="italic text-moss-600">eine Haltung.</span>
</h2>
</div>
<div className="md:col-span-7 md:col-start-6 space-y-6 text-ink/80 leading-relaxed">
<p className="text-lg">
Wir vermieten keine Hotelzimmer und führen keine Rezeption. Was wir anbieten,
sind zwei Wohnungen, die wir selbst bewohnen würden und manchmal tun.
</p>
<p>
Kahnblick liegt am Hafen von Lübbenau, Erlenhof steht unter den alten Bäumen
in Burg. Beide sind verschieden, beide sind still. Beide sind gedacht für
Menschen, die lieber einen Tag länger bleiben als einen schneller weiter.
</p>
<figure className="pt-6 border-t border-ink/10 mt-10">
<blockquote className="font-display text-2xl md:text-3xl leading-tight italic text-moss-700">
Wir glauben, dass ein gutes Frühstück länger dauern darf als eine
Vorstandssitzung."
</blockquote>
<figcaption className="mt-4 text-sm text-ink/55">
Familie Musterfrau, Gastgeber
</figcaption>
</figure>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,138 @@
import Image from "next/image";
import Link from "next/link";
import type { Apartment } from "@/types";
import { formatPrice } from "@/lib/utils";
export function ApartmentPreview({
apartments,
}: {
apartments: Apartment[];
}) {
return (
<section id="wohnungen" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-14 md:mb-20">
<div>
<div className="eyebrow mb-4">Unsere Wohnungen</div>
<h2 className="font-display text-display-lg max-w-2xl leading-[1.02]">
Zwei Orte, an die man<br className="hidden md:inline" />{" "}
<span className="italic text-moss-600">gerne zurückkehrt</span>.
</h2>
</div>
<p className="text-ink/65 max-w-sm text-sm md:text-base">
Jedes Haus hat seine eigene Geschichte wählen Sie, was für Ihre
nächste Auszeit passt.
</p>
</div>
<div className="space-y-20 md:space-y-28">
{apartments.map((apt, i) => {
const isEven = i % 2 === 0;
const hasPlatforms = apt.airbnbUrl || apt.bookingUrl;
return (
<article
key={apt.id}
className="grid md:grid-cols-12 gap-8 md:gap-14 items-center"
>
<Link
href={`/wohnungen/${apt.slug}`}
className={`md:col-span-7 relative aspect-[3/2] md:aspect-[4/3] overflow-hidden rounded-sm group ${
isEven ? "" : "md:col-start-6"
}`}
>
<Image
src={apt.images[0] ?? ""}
alt={apt.name}
fill
sizes="(max-width: 768px) 100vw, 58vw"
className="object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="absolute top-5 left-5 bg-ink/60 backdrop-blur-sm text-parchment px-3 py-1 text-xs font-medium rounded-full tracking-widest">
0{i + 1}
</div>
</Link>
<div
className={`md:col-span-5 ${
isEven ? "md:col-start-8" : "md:col-start-1 md:row-start-1"
}`}
>
<div className="eyebrow mb-3">{apt.tagline}</div>
<h3 className="font-display text-4xl md:text-5xl mb-5 leading-none">
{apt.name}
</h3>
<p className="text-ink/75 leading-relaxed mb-6">
{apt.shortDescription}
</p>
<dl className="grid grid-cols-3 gap-4 py-5 border-y border-ink/10 text-sm">
<div>
<dt className="eyebrow mb-1.5">Gäste</dt>
<dd className="font-display text-2xl leading-none">
bis {apt.maxGuests}
</dd>
</div>
<div>
<dt className="eyebrow mb-1.5">Größe</dt>
<dd className="font-display text-2xl leading-none">{apt.sizeSqm} m²</dd>
</div>
<div>
<dt className="eyebrow mb-1.5">ab / Nacht</dt>
<dd className="font-display text-2xl leading-none text-moss-700">
{formatPrice(apt.priceFrom)}
</dd>
</div>
</dl>
<div className="mt-7 flex flex-wrap gap-3">
<Link
href={`/wohnungen/${apt.slug}`}
className="inline-flex items-center gap-2 bg-ink text-parchment px-6 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Wohnung ansehen
<span aria-hidden></span>
</Link>
<Link
href={`/anfrage?wohnung=${apt.slug}`}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full text-sm border border-ink/15 hover:border-ink/30 hover:bg-ink/5 transition"
>
Direkt anfragen
</Link>
</div>
{hasPlatforms && (
<div className="mt-5 flex items-center gap-3 text-xs text-ink/45">
<span>Auch auf</span>
{apt.airbnbUrl && (
<a
href={apt.airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="text-ink/60 hover:text-ink transition underline underline-offset-2"
>
Airbnb
</a>
)}
{apt.airbnbUrl && apt.bookingUrl && <span className="text-ink/25">·</span>}
{apt.bookingUrl && (
<a
href={apt.bookingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-ink/60 hover:text-ink transition underline underline-offset-2"
>
Booking.com
</a>
)}
</div>
)}
</div>
</article>
);
})}
</div>
</div>
</section>
);
}

78
components/home/Hero.tsx Normal file
View File

@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
export function Hero() {
return (
<section className="-mt-[72px] md:-mt-[80px] relative min-h-[100vh] flex flex-col overflow-hidden">
{/* Full-bleed background image */}
<div className="absolute inset-0 z-0">
<Image
src="/images/b6ca2c_a39e632a73b944dbbd6887cdb627223d~mv2.avif"
alt="Nebel über dem Spreewald"
fill
priority
sizes="100vw"
className="object-cover"
/>
{/* Base dark veil */}
<div className="absolute inset-0 bg-ink/40" />
{/* Strong bottom-to-top gradient for text area */}
<div className="absolute inset-0 bg-gradient-to-t from-ink/85 via-ink/50 to-ink/15" />
{/* Left-side reinforcement for text column */}
<div className="absolute inset-0 bg-gradient-to-r from-ink/50 via-ink/20 to-transparent" />
</div>
{/* Content — pinned to bottom */}
<div className="relative z-10 mt-auto container pb-16 md:pb-24 pt-32 md:pt-40">
<div className="max-w-4xl">
<div className="eyebrow !text-parchment/60 mb-6">Ferienwohnungen · Spreewald</div>
<h1 className="font-display text-display-xl text-parchment leading-[0.96]" style={{ textShadow: "0 2px 24px rgba(28,38,32,0.5)" }}>
Hier hat der<br />
Morgen noch{" "}
<span className="italic text-moss-300">Nebel</span>,<br />
und die Nacht<br />
noch{" "}
<span className="italic text-moss-300">Sterne</span>.
</h1>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<Link
href="#wohnungen"
className="inline-flex items-center justify-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm font-medium hover:bg-cream transition-colors"
>
Die Wohnungen ansehen
<span aria-hidden></span>
</Link>
<Link
href="/anfrage"
className="inline-flex items-center justify-center gap-2 border border-parchment/30 text-parchment px-7 py-3.5 rounded-full text-sm hover:border-parchment/60 hover:bg-parchment/10 transition"
>
Anfrage senden
</Link>
</div>
{/* Trust signals */}
<div className="mt-10 pt-8 border-t border-parchment/15 flex flex-wrap gap-6 text-xs text-parchment/50">
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Keine Provision
</span>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Direkt beim Gastgeber
</span>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Antwort in 24 Stunden
</span>
</div>
</div>
</div>
{/* Bottom scroll hint */}
<div className="relative z-10 container pb-8 flex items-center justify-end">
<span className="text-xs text-parchment/35 tracking-[0.2em] uppercase">Scrollen</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,136 @@
import Image from "next/image";
import Link from "next/link";
import { SpreeMap } from "./SpreeMap";
const highlights = [
{
title: "Die Kähne",
text: "Vom Hafen Lübbenau starten täglich die traditionellen Spreewaldkähne. Drei Stunden durch das UNESCO-Biosphärenreservat — ohne Motor, mit Schilf und Stille.",
image: "https://images.unsplash.com/photo-1528181304800-259b08848526?auto=format&fit=crop&w=800&q=75",
},
{
title: "Die Wege",
text: "Über 400 km Radwege führen durch die Region. Unsere Fahrräder stehen für Sie bereit, Tourentipps liegen in jeder Wohnung aus.",
image: "https://images.unsplash.com/photo-1517649763962-0c623066013b?auto=format&fit=crop&w=800&q=75",
},
{
title: "Der Tisch",
text: "Gurken, Leinöl, frischer Fisch, sorbische Hochzeitssuppe — die Spreewaldküche ist bodenständig und überraschend. Wir haben unsere Lieblingsadressen notiert.",
image: "https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?auto=format&fit=crop&w=800&q=75",
},
];
export function Location() {
return (
<section id="umgebung" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="max-w-3xl mb-14 md:mb-20">
<div className="eyebrow mb-4">Die Umgebung</div>
<h2 className="font-display text-display-lg leading-[1.02]">
Zwischen Wasser,<br />
<span className="italic text-moss-600">Wald und Wegen.</span>
</h2>
<p className="mt-6 text-ink/70 max-w-xl leading-relaxed">
Der Spreewald ist ein UNESCO-Biosphärenreservat. Weniger als 90 Minuten
von Berlin entfernt und doch eine andere Welt.
</p>
</div>
{/* Highlight cards */}
<div className="grid md:grid-cols-3 gap-6 md:gap-8">
{highlights.map((h) => (
<article
key={h.title}
className="group bg-cream/60 rounded-sm overflow-hidden"
>
<div className="relative aspect-[5/4] overflow-hidden">
<Image
src={h.image}
alt={h.title}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.04]"
/>
</div>
<div className="p-6 md:p-7">
<h3 className="font-display text-2xl mb-3">{h.title}</h3>
<p className="text-sm text-ink/75 leading-relaxed">{h.text}</p>
</div>
</article>
))}
</div>
{/* Map */}
<div className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Wo wir sind</div>
<h3 className="font-display text-2xl md:text-3xl mb-3 leading-tight">
Vetschau/Spreewald {" "}
<span className="italic text-moss-600">mitten im Biosphärenreservat</span>.
</h3>
<p className="text-ink/65 text-sm mb-6 max-w-xl">
Kraftwerkstraße 10 · 03226 Vetschau. Klicken Sie auf den Marker für
die Routenplanung.
</p>
<div className="relative overflow-hidden rounded-sm border border-ink/10 shadow-[0_1px_3px_rgba(28,38,32,0.04),0_4px_16px_rgba(28,38,32,0.05)]">
<SpreeMap />
<div className="absolute inset-0 pointer-events-none ring-1 ring-inset ring-ink/8 rounded-sm" />
</div>
{/* Address cards */}
<div className="mt-5 grid sm:grid-cols-2 gap-4">
<div className="flex items-start justify-between gap-4 bg-cream/60 border border-ink/10 rounded-sm px-5 py-4">
<div>
<div className="eyebrow mb-1">Spreewaldzeit</div>
<div className="font-display text-lg leading-tight">Kraftwerkstraße 10</div>
<div className="text-sm text-ink/65 mt-1">03226 Vetschau/Spreewald</div>
</div>
<a
href="https://www.google.com/maps/dir/?api=1&destination=51.7753,14.0966"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs text-moss-700 border border-moss-300 px-3 py-1.5 rounded-full hover:bg-moss-50 transition-colors mt-1"
>
Route
</a>
</div>
<div className="flex items-start justify-between gap-4 bg-cream/60 border border-ink/10 rounded-sm px-5 py-4">
<div>
<div className="eyebrow mb-1">Anfahrt</div>
<div className="font-display text-lg leading-tight">Berlin Vetschau</div>
<div className="text-sm text-ink/65 mt-1">ca. 90 Min · A13 Richtung Cottbus</div>
</div>
<a
href="https://www.google.com/maps/dir/?api=1&destination=51.7753,14.0966"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs text-moss-700 border border-moss-300 px-3 py-1.5 rounded-full hover:bg-moss-50 transition-colors mt-1"
>
Route
</a>
</div>
</div>
</div>
{/* CTA */}
<div className="mt-16 md:mt-20 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 p-8 md:p-10 bg-moss-800 text-parchment rounded-sm">
<div>
<div className="eyebrow !text-parchment/60 mb-2">Bereit?</div>
<h3 className="font-display text-2xl md:text-3xl leading-tight">
Fragen Sie unverbindlich an <br className="hidden md:block" />
wir melden uns innerhalb von 24 Stunden.
</h3>
</div>
<Link
href="/anfrage"
className="inline-flex items-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm hover:bg-cream transition-colors shrink-0"
>
Zur Anfrage
<span aria-hidden></span>
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useRef } from "react";
type Category = "Natur" | "Museum" | "Wellness" | "Erlebnis" | "Restaurant" | "Ausflug";
interface Place {
name: string;
category: Category;
distance: string;
description: string;
mapsQuery: string;
rating?: string;
}
const PLACES: Place[] = [
{
name: "Spreewaldfahrt Familie Goertz",
category: "Ausflug",
distance: "12 km",
description:
"Traditionelle Kahnfahrt durch das UNESCO-Biosphärenreservat. Stille Fließe, Schilf und die Geschichte des Spreewalds — erzählt vom Kahnführer.",
mapsQuery: "Spreewaldfahrt+Familie+Goertz+Lübbenau",
rating: "4.7",
},
{
name: "Spreewald Therme",
category: "Wellness",
distance: "15 km",
description:
"Entspannung nach einem Wandertag: große Saunalandschaft, Außenbecken und Ruhebereich mitten im Biosphärenreservat. Burg (Spreewald).",
mapsQuery: "Spreewald+Therme+Burg",
rating: "4.2",
},
{
name: "Slawischer Burgwall Raddusch",
category: "Museum",
distance: "5 km",
description:
"Rekonstruierter Ringwall aus dem 9. Jahrhundert — das älteste sichtbare Baudenkmal der Region. Mit Aussichtsplattform über die Teiche.",
mapsQuery: "Slawischer+Burgwall+Raddusch",
rating: "4.4",
},
{
name: "Freilandmuseum Lehde",
category: "Museum",
distance: "20 km",
description:
"Vier original erhaltene Spreewaldgehöfte aus dem 19. Jahrhundert — Einblick in das Leben der sorbischen Bevölkerung. Im Dorf Lehde, nur per Kahn oder Rad erreichbar.",
mapsQuery: "Freilandmuseum+Lehde+Spreewald",
rating: "4.5",
},
{
name: "Gurkenradweg",
category: "Natur",
distance: "direkt",
description:
"250 km Radwegenetz durch Gurkenfelder, Wasserwege und Dörfer — das Herzstück des Spreewalds. Flach, gut beschildert, für jedes Tempo geeignet.",
mapsQuery: "Gurkenradweg+Spreewald",
},
{
name: "Tropical Islands",
category: "Erlebnis",
distance: "35 km",
description:
"Die größte Indoortropenwelt der Welt in einer umgebauten Luftschiffhalle. Strand, Wasserrutschen, Regenwald und Sauna unter einem Dach.",
mapsQuery: "Tropical+Islands+Brand",
rating: "4.3",
},
{
name: "Schloss & Park Branitz",
category: "Natur",
distance: "28 km",
description:
"Das Lebenswerk des exzentrischen Fürsten Pückler-Muskau: ein englischer Landschaftspark mit einzigartigen Erdpyramiden und barockem Schloss in Cottbus.",
mapsQuery: "Schloss+Branitz+Cottbus",
rating: "4.6",
},
{
name: "Spreewood Distillers",
category: "Erlebnis",
distance: "18 km",
description:
"Whisky, Gin und Liköre aus dem Biosphärenreservat. Führungen durch die Destillerie, Tastings und ein kleiner Shop direkt vor Ort in Schlepzig.",
mapsQuery: "Spreewood+Distillers+Schlepzig",
rating: "4.2",
},
{
name: "Biberhof & Aquarium",
category: "Natur",
distance: "15 km",
description:
"Biber, Fischotter, Fischadler — die typischen Bewohner des Spreewalds zum Anfassen nah. Beliebtes Ausflugsziel für Familien in Raddusch.",
mapsQuery: "Biberhof+Raddusch+Spreewald",
rating: "4.2",
},
{
name: "Bismarckturm Burg",
category: "Ausflug",
distance: "17 km",
description:
"Historischer Aussichtsturm auf einem der wenigen Hügel des Spreewalds. Bei klarem Wetter Panoramablick über das gesamte Biosphärenreservat.",
mapsQuery: "Bismarckturm+Burg+Spreewald",
rating: "3.6",
},
];
const categoryStyles: Record<Category, { bg: string; text: string; dot: string }> = {
Natur: { bg: "bg-moss-50", text: "text-moss-700", dot: "bg-moss-500" },
Museum: { bg: "bg-sand-50", text: "text-sand-700", dot: "bg-sand-500" },
Wellness: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-400" },
Erlebnis: { bg: "bg-rose-50", text: "text-rose-700", dot: "bg-rose-400" },
Restaurant:{ bg: "bg-orange-50", text: "text-orange-700", dot: "bg-orange-400" },
Ausflug: { bg: "bg-teal-50", text: "text-teal-700", dot: "bg-teal-500" },
};
export function PlacesToVisit() {
const scrollRef = useRef<HTMLDivElement>(null);
const scroll = (dir: "left" | "right") => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir === "right" ? 320 : -320, behavior: "smooth" });
};
return (
<section id="ausflugsziele" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10 md:mb-14">
<div>
<div className="eyebrow mb-4">Die Region</div>
<h2 className="font-display text-display-lg max-w-2xl leading-[1.02]">
Was Sie nicht verpassen{" "}
<span className="italic text-moss-600">sollten.</span>
</h2>
</div>
<div className="flex items-center gap-4">
<p className="text-ink/60 max-w-xs text-sm md:text-base shrink-0 hidden md:block">
Ausgewählte Highlights im Umkreis von 50 km.
</p>
{/* Arrow buttons */}
<div className="flex gap-2 shrink-0">
<button
onClick={() => scroll("left")}
aria-label="Zurück"
className="h-9 w-9 rounded-full border border-ink/15 bg-cream flex items-center justify-center text-ink/50 hover:border-ink/30 hover:text-ink/80 transition-colors"
>
</button>
<button
onClick={() => scroll("right")}
aria-label="Weiter"
className="h-9 w-9 rounded-full border border-ink/15 bg-cream flex items-center justify-center text-ink/50 hover:border-ink/30 hover:text-ink/80 transition-colors"
>
</button>
</div>
</div>
</div>
{/* Horizontal scroll container */}
<div className="relative">
{/* Fade edges */}
<div className="pointer-events-none absolute left-0 top-0 bottom-4 w-8 bg-gradient-to-r from-parchment to-transparent z-10" />
<div className="pointer-events-none absolute right-0 top-0 bottom-4 w-16 bg-gradient-to-l from-parchment to-transparent z-10" />
<div
ref={scrollRef}
className="flex gap-4 overflow-x-auto pb-4 scroll-smooth snap-x snap-mandatory"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{PLACES.map((place) => {
const style = categoryStyles[place.category];
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${place.mapsQuery}`;
return (
<a
key={place.name}
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="group snap-start shrink-0 w-[280px] md:w-[300px] bg-cream border border-ink/10 rounded-sm p-5 md:p-6 flex flex-col hover:border-ink/25 hover:shadow-card transition-all duration-200"
>
{/* Top row: category + distance */}
<div className="flex items-center justify-between mb-4">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.15em] px-2.5 py-1 rounded-full ${style.bg} ${style.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} aria-hidden />
{place.category}
</span>
<span className="text-xs text-ink/45 tabular-nums">{place.distance}</span>
</div>
{/* Name */}
<h3 className="font-display text-xl leading-tight mb-3 group-hover:text-moss-700 transition-colors">
{place.name}
</h3>
{/* Description */}
<p className="text-sm text-ink/70 leading-relaxed flex-1">
{place.description}
</p>
{/* Footer: rating + link hint */}
<div className="mt-4 pt-4 border-t border-ink/8 flex items-center justify-between">
{place.rating ? (
<span className="text-xs text-ink/50 flex items-center gap-1">
<span className="text-sand-500"></span>
{place.rating} auf TripAdvisor
</span>
) : (
<span />
)}
<span className="text-xs text-ink/40 group-hover:text-moss-600 transition-colors">
Maps
</span>
</div>
</a>
);
})}
</div>
</div>
<p className="mt-3 text-xs text-ink/40">
Alle Entfernungen ab Vetschau/Spreewald. Bewertungen via TripAdvisor.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,15 @@
export function SpreeMap() {
return (
<iframe
src="https://maps.google.com/maps?q=Kraftwerkstra%C3%9Fe+10%2C+03226+Vetschau%2FSpreewald&z=15&output=embed"
width="100%"
height="300"
className="md:!h-[440px]"
style={{ border: 0, display: "block" }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Spreewaldzeit Kraftwerkstraße 10, Vetschau/Spreewald"
/>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import Link from "next/link";
import { inquirySchema, type InquiryInput } from "@/lib/validations";
import { Input, Textarea, Label, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils";
interface ApartmentOption {
slug: string;
name: string;
}
interface Props {
apartments: ApartmentOption[];
defaultSlug?: string;
defaultArrival?: string;
defaultDeparture?: string;
}
export function InquiryForm({ apartments, defaultSlug, defaultArrival, defaultDeparture }: Props) {
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<InquiryInput>({
resolver: zodResolver(inquirySchema),
defaultValues: {
apartmentSlug: defaultSlug && apartments.some((a) => a.slug === defaultSlug) ? defaultSlug : apartments[0]?.slug,
arrival: defaultArrival ?? "",
departure: defaultDeparture ?? "",
guests: 2,
gdpr: false as unknown as true,
website: "",
},
});
async function onSubmit(values: InquiryInput) {
setState("loading");
setServerError(null);
try {
const res = await fetch("/api/inquiries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Anfrage konnte nicht gesendet werden.");
}
reset();
setState("success");
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) {
setState("error");
setServerError(err instanceof Error ? err.message : "Unbekannter Fehler.");
}
}
if (state === "success") {
return (
<div className="bg-moss-50 border border-moss-200 rounded-sm p-8 md:p-12 animate-fade-up">
<div className="eyebrow mb-3">Danke!</div>
<h2 className="font-display text-3xl md:text-4xl leading-tight mb-4">
Ihre Anfrage ist bei uns.
</h2>
<p className="text-ink/75 max-w-xl leading-relaxed">
Wir haben Ihnen eine Bestätigung per E-Mail geschickt und melden uns in
der Regel innerhalb von 24 Stunden.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/" className="inline-flex items-center gap-2 bg-ink text-parchment px-6 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors">
Zur Startseite
</Link>
<button
onClick={() => setState("idle")}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full text-sm border border-ink/20 hover:bg-ink/5"
>
Weitere Anfrage senden
</button>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-10" noValidate>
{/* Honeypot */}
<div className="absolute left-[-9999px] w-0 h-0 overflow-hidden" aria-hidden="true">
<label>
Website (bitte leer lassen)
<input type="text" tabIndex={-1} autoComplete="off" {...register("website")} />
</label>
</div>
{/* Wohnung */}
<div>
<Label htmlFor="apartmentSlug">Wohnung</Label>
<div className="relative mt-2">
<select
id="apartmentSlug"
{...register("apartmentSlug")}
className={cn(
"w-full appearance-none bg-transparent border-b py-2.5 text-base pr-8",
errors.apartmentSlug ? "border-red-600" : "border-ink/25 focus:border-ink",
"focus:outline-none transition-colors"
)}
>
{apartments.map((a) => (
<option key={a.slug} value={a.slug}>{a.name}</option>
))}
</select>
<span className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 text-ink/40" aria-hidden></span>
</div>
<FieldError message={errors.apartmentSlug?.message} />
</div>
{/* Datum + Gäste */}
<div className="grid md:grid-cols-3 gap-8">
<div>
<Label htmlFor="arrival">Anreise</Label>
<Input id="arrival" type="date" error={errors.arrival?.message} {...register("arrival")} />
<FieldError message={errors.arrival?.message} />
</div>
<div>
<Label htmlFor="departure">Abreise</Label>
<Input id="departure" type="date" error={errors.departure?.message} {...register("departure")} />
<FieldError message={errors.departure?.message} />
</div>
<div>
<Label htmlFor="guests">Personen</Label>
<Input id="guests" type="number" min={1} max={20} error={errors.guests?.message} {...register("guests")} />
<FieldError message={errors.guests?.message} />
</div>
</div>
<hr className="border-ink/10" />
{/* Kontakt */}
<div className="grid md:grid-cols-2 gap-8">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" type="text" placeholder="Ihr Name" autoComplete="name" error={errors.name?.message} {...register("name")} />
<FieldError message={errors.name?.message} />
</div>
<div>
<Label htmlFor="email">E-Mail</Label>
<Input id="email" type="email" placeholder="name@example.com" autoComplete="email" error={errors.email?.message} {...register("email")} />
<FieldError message={errors.email?.message} />
</div>
</div>
<div>
<Label htmlFor="phone">
Telefon <span className="text-ink/40 normal-case tracking-normal">(optional)</span>
</Label>
<Input id="phone" type="tel" placeholder="+49 …" autoComplete="tel" {...register("phone")} />
</div>
{/* Nachricht */}
<div>
<Label htmlFor="message">
Nachricht <span className="text-ink/40 normal-case tracking-normal">(optional)</span>
</Label>
<Textarea
id="message"
placeholder="Reisen Sie mit Hund? Gibt es Wünsche, die wir wissen sollten?"
className="mt-2"
rows={5}
{...register("message")}
/>
</div>
{/* DSGVO */}
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
{...register("gdpr")}
className="mt-1 h-4 w-4 accent-moss-600 shrink-0"
/>
<span className="text-sm text-ink/75 leading-relaxed">
Ich habe die{" "}
<Link href="/datenschutz" className="link-underline text-ink">
Datenschutzerklärung
</Link>{" "}
gelesen und stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu.
</span>
</label>
<FieldError message={errors.gdpr?.message as string | undefined} />
</div>
{serverError && (
<div className="border border-red-200 bg-red-50 text-red-800 px-4 py-3 rounded-sm text-sm">
{serverError}
</div>
)}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 pt-2">
<Button type="submit" size="lg" disabled={state === "loading"}>
{state === "loading" ? "Wird gesendet…" : "Anfrage senden →"}
</Button>
<span className="text-xs text-ink/50">
Unverbindlich · Sie erhalten eine Bestätigung per E-Mail.
</span>
</div>
</form>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function Footer() {
const pathname = usePathname();
if (pathname?.startsWith("/admin")) return null;
const year = new Date().getFullYear();
return (
<footer className="mt-24 md:mt-32 border-t border-ink/10">
{/* Big editorial tagline band */}
<div className="bg-moss-800 text-parchment overflow-hidden">
<div className="container py-16 md:py-24">
<p className="font-display text-[clamp(2.5rem,8vw,6rem)] leading-[0.95] tracking-tight">
Zeit haben.<br />
Luft holen.<br />
<span className="italic text-moss-300">Bleiben.</span>
</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link
href="/anfrage"
className="inline-flex items-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm font-medium hover:bg-cream transition-colors"
>
Jetzt anfragen
</Link>
</div>
</div>
</div>
{/* Links & info row */}
<div className="bg-cream/60">
<div className="container py-12 md:py-16 grid grid-cols-2 md:grid-cols-12 gap-10 md:gap-12">
<div className="col-span-2 md:col-span-4">
<div className="font-display text-2xl mb-3">Spreewaldzeit</div>
<p className="text-ink/65 text-sm leading-relaxed max-w-xs">
Zwei private Ferienwohnungen im Spreewald. Keine Rezeption, keine Masse
nur Sie, das Wasser und die Bäume.
</p>
</div>
<div className="col-span-2 md:col-span-3 md:col-start-6">
<div className="eyebrow mb-4">Kontakt</div>
<address className="not-italic text-sm leading-relaxed text-ink/75">
Spreewaldzeit<br />
Familie Musterfrau<br />
Kraftwerkstraße 10<br />
03226 Vetschau/Spreewald
</address>
<div className="mt-4 text-sm text-ink/75">
<a href="mailto:hallo@spreewaldzeit.de" className="link-underline">
hallo@spreewaldzeit.de
</a>
</div>
</div>
<div className="md:col-span-2 md:col-start-9 col-span-1">
<div className="eyebrow mb-4">Navigation</div>
<ul className="space-y-2 text-sm text-ink/75">
<li><Link href="/#wohnungen" className="link-underline">Die Wohnungen</Link></li>
<li><Link href="/#umgebung" className="link-underline">Umgebung</Link></li>
<li><Link href="/anfrage" className="link-underline">Anfrage senden</Link></li>
<li><Link href="/datenschutz" className="link-underline">Datenschutz</Link></li>
<li><Link href="/impressum" className="link-underline">Impressum</Link></li>
</ul>
</div>
<div className="md:col-span-2 md:col-start-11 col-span-1">
<div className="eyebrow mb-4">Plattformen</div>
<ul className="space-y-2 text-sm text-ink/75">
<li>
<a href="https://www.airbnb.de" target="_blank" rel="noopener noreferrer" className="link-underline">
Airbnb
</a>
</li>
<li>
<a href="https://www.booking.com" target="_blank" rel="noopener noreferrer" className="link-underline">
Booking.com
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-ink/10">
<div className="container py-5 flex flex-col md:flex-row justify-between gap-2 text-xs text-ink/40">
<span>© {year} Spreewaldzeit. Alle Rechte vorbehalten.</span>
<span>Mit Sorgfalt gemacht im Spreewald.</span>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/#wohnungen", label: "Wohnungen" },
{ href: "/#umgebung", label: "Umgebung" },
{ href: "/anfrage", label: "Anfrage" },
];
export function Header() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const isHome = pathname === "/";
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// On non-home pages the header is always opaque
const transparent = isHome && !scrolled;
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
if (pathname?.startsWith("/admin")) return null;
return (
<header
className={cn(
"fixed top-0 left-0 right-0 z-40 transition-all duration-300",
transparent
? "bg-transparent"
: "bg-parchment/95 backdrop-blur-md border-b border-ink/8 shadow-[0_1px_12px_rgba(28,38,32,0.06)]"
)}
>
<div className="container flex items-center justify-between py-5 md:py-6">
<Link href="/" className="flex items-baseline gap-2.5 group">
<span className={cn(
"font-display text-2xl md:text-[1.7rem] tracking-tight leading-none transition-colors duration-200",
transparent ? "text-parchment group-hover:text-moss-200" : "text-ink group-hover:text-moss-700"
)}>
Spreewaldzeit
</span>
<span
className="hidden sm:inline-block h-1.5 w-1.5 rounded-full bg-moss-400 opacity-70 group-hover:opacity-100 group-hover:scale-110 transition-all duration-200"
aria-hidden="true"
/>
</Link>
<nav className="hidden md:flex items-center gap-9 text-sm">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"link-underline transition-colors duration-200",
transparent ? "text-parchment/70 hover:text-parchment" : "text-ink/70 hover:text-ink"
)}
>
{item.label}
</Link>
))}
<Link
href="/anfrage"
className={cn(
"inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm transition-colors",
transparent
? "bg-parchment/15 text-parchment border border-parchment/25 hover:bg-parchment/25"
: "bg-ink text-parchment hover:bg-moss-700"
)}
>
Jetzt anfragen
<span aria-hidden="true"></span>
</Link>
</nav>
<button
className="md:hidden p-2 -mr-2 rounded-sm transition"
onClick={() => setMobileOpen((v) => !v)}
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={mobileOpen}
>
<span className="relative block w-6 h-[2px]">
<span
className={cn(
"absolute inset-x-0 h-[2px] transition-all duration-300",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen ? "top-0 rotate-45" : "-top-2"
)}
/>
<span
className={cn(
"absolute inset-x-0 h-[2px] top-0 transition-opacity",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen && "opacity-0"
)}
/>
<span
className={cn(
"absolute inset-x-0 h-[2px] transition-all duration-300",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen ? "top-0 -rotate-45" : "top-2"
)}
/>
</span>
</button>
</div>
{/* Mobile menu */}
<div
className={cn(
"md:hidden overflow-hidden border-t border-ink/8 bg-parchment/95 backdrop-blur-md transition-[max-height,opacity] duration-300",
mobileOpen ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0"
)}
>
<nav className="container flex flex-col py-5 gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="py-3 text-lg font-display hover:text-moss-600 transition-colors"
>
{item.label}
</Link>
))}
<Link
href="/anfrage"
className="mt-3 inline-flex items-center justify-center bg-ink text-parchment px-5 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Jetzt anfragen
</Link>
</nav>
</div>
</header>
);
}

43
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
import { forwardRef, type ButtonHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "outline";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const variantClasses: Record<Variant, string> = {
primary:
"bg-ink text-parchment hover:bg-moss-700 disabled:bg-ink/40",
secondary:
"bg-moss-500 text-parchment hover:bg-moss-600 disabled:bg-moss-500/40",
outline:
"border border-ink/20 text-ink hover:border-ink/40 hover:bg-ink/5",
ghost:
"text-ink hover:bg-ink/5",
};
const sizeClasses: Record<Size, string> = {
sm: "px-3.5 py-2 text-xs",
md: "px-5 py-2.5 text-sm",
lg: "px-7 py-3.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
/>
)
);
Button.displayName = "Button";

80
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type LabelHTMLAttributes } from "react";
// -----------------------------------------------------------------
// Label
// -----------------------------------------------------------------
export function Label({
className,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn(
"text-xs uppercase tracking-[0.18em] text-ink/70 font-medium",
className
)}
{...props}
/>
);
}
// -----------------------------------------------------------------
// Input
// -----------------------------------------------------------------
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, error, ...props }, ref) => (
<input
ref={ref}
className={cn(
"w-full bg-transparent border-b py-2.5 text-base",
"placeholder:text-ink/40 focus:outline-none",
error
? "border-red-600 focus:border-red-700"
: "border-ink/25 focus:border-ink",
"transition-colors",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";
// -----------------------------------------------------------------
// Textarea
// -----------------------------------------------------------------
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"w-full bg-transparent border py-3 px-3 text-base rounded-md",
"placeholder:text-ink/40 focus:outline-none resize-y min-h-[120px]",
error
? "border-red-600 focus:border-red-700"
: "border-ink/25 focus:border-ink",
"transition-colors",
className
)}
{...props}
/>
)
);
Textarea.displayName = "Textarea";
// -----------------------------------------------------------------
// FieldError ein einheitlicher Fehler-Text
// -----------------------------------------------------------------
export function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="mt-1.5 text-xs text-red-700">{message}</p>;
}