Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
68
components/admin/AdminNav.tsx
Normal file
68
components/admin/AdminNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
components/admin/ApartmentEditor.tsx
Normal file
321
components/admin/ApartmentEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
components/admin/CalendarManager.tsx
Normal file
288
components/admin/CalendarManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
components/admin/InquiryRow.tsx
Normal file
159
components/admin/InquiryRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
components/apartment/AvailabilityCalendar.tsx
Normal file
87
components/apartment/AvailabilityCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
components/apartment/AvailablePeriods.tsx
Normal file
104
components/apartment/AvailablePeriods.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
components/apartment/BookingPlatforms.tsx
Normal file
67
components/apartment/BookingPlatforms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/apartment/Features.tsx
Normal file
26
components/apartment/Features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
components/apartment/Gallery.tsx
Normal file
168
components/apartment/Gallery.tsx
Normal 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
53
components/home/About.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
components/home/ApartmentPreview.tsx
Normal file
138
components/home/ApartmentPreview.tsx
Normal 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
78
components/home/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
components/home/Location.tsx
Normal file
136
components/home/Location.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
components/home/PlacesToVisit.tsx
Normal file
228
components/home/PlacesToVisit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
components/home/SpreeMap.tsx
Normal file
15
components/home/SpreeMap.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
216
components/inquiry/InquiryForm.tsx
Normal file
216
components/inquiry/InquiryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
components/layout/Footer.tsx
Normal file
95
components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
components/layout/Header.tsx
Normal file
145
components/layout/Header.tsx
Normal 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
43
components/ui/Button.tsx
Normal 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
80
components/ui/Input.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user