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

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

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import { InquiryRow, type InquiryRowData } from "@/components/admin/InquiryRow";
import type { InquiryStatus } from "@/types";
import { INQUIRY_STATUS_LABELS } from "@/types";
import Link from "next/link";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
interface PageProps {
searchParams: { status?: string };
}
const FILTERS: Array<{ key: InquiryStatus | "all"; label: string }> = [
{ key: "all", label: "Alle" },
{ key: "new", label: "Neu" },
{ key: "read", label: "Gelesen" },
{ key: "confirmed", label: "Bestätigt" },
{ key: "declined", label: "Abgelehnt" },
{ key: "archived", label: "Archiviert" },
];
export default async function AdminInquiriesPage({ searchParams }: PageProps) {
const filter = (searchParams.status ?? "all") as InquiryStatus | "all";
const inquiries = await prisma.inquiry.findMany({
where: filter === "all" ? {} : { status: filter },
include: { apartment: { select: { name: true } } },
orderBy: { createdAt: "desc" },
});
const newCount = await prisma.inquiry.count({ where: { status: "new" } });
const rows: InquiryRowData[] = inquiries.map((i) => ({
id: i.id,
apartmentName: i.apartment.name,
arrival: i.arrival.toISOString(),
departure: i.departure.toISOString(),
guests: i.guests,
name: i.name,
email: i.email,
phone: i.phone,
message: i.message,
status: i.status as InquiryStatus,
createdAt: i.createdAt.toISOString(),
}));
return (
<div className="container py-10 md:py-14">
<div className="flex items-end justify-between gap-6 mb-8">
<div>
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">
Anfragen
</h1>
<p className="text-ink/60 text-sm mt-2">
{newCount > 0
? `${newCount} neue Anfrage${newCount === 1 ? "" : "n"} wartet auf Ihre Rückmeldung.`
: "Alle Anfragen sind bearbeitet."}
</p>
</div>
</div>
{/* Filter */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTERS.map((f) => {
const active = filter === f.key;
const href = f.key === "all" ? "/admin/anfragen" : `/admin/anfragen?status=${f.key}`;
return (
<Link
key={f.key}
href={href}
className={cn(
"px-3.5 py-1.5 rounded-full text-xs uppercase tracking-wider transition",
active
? "bg-ink text-parchment"
: "bg-cream border border-ink/15 text-ink/70 hover:border-ink/30"
)}
>
{f.label}
</Link>
);
})}
</div>
{rows.length === 0 ? (
<div className="bg-cream border border-ink/10 rounded-sm p-12 text-center text-ink/55">
Keine Anfragen in dieser Ansicht.
</div>
) : (
<ul className="space-y-3">
{rows.map((row) => (
<InquiryRow key={row.id} inquiry={row} />
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
import { CalendarManager, type BlockRow } from "@/components/admin/CalendarManager";
export const dynamic = "force-dynamic";
export default async function AdminKalenderPage() {
const apartments = await prisma.apartment.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, slug: true, name: true },
});
const blocks = await prisma.block.findMany({
include: { apartment: { select: { name: true } } },
orderBy: { startDate: "asc" },
});
const rows: BlockRow[] = blocks.map((b) => ({
id: b.id,
apartmentId: b.apartmentId,
apartmentName: b.apartment.name,
startDate: b.startDate.toISOString(),
endDate: b.endDate.toISOString(),
reason: b.reason,
source: b.source,
note: b.note,
}));
return (
<div className="container py-10 md:py-14">
<div className="mb-10">
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">Kalender</h1>
<p className="text-ink/60 text-sm mt-2">
Zeiträume sperren oder freigeben. Für jede Wohnung getrennt.
</p>
</div>
<CalendarManager apartments={apartments} blocks={rows} />
</div>
);
}

22
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { getSession } from "@/lib/auth";
import { AdminNav } from "@/components/admin/AdminNav";
export const dynamic = "force-dynamic";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
// Die Middleware (middleware.ts) schützt alle /admin-Routen außer /admin/login.
// Hier holen wir nur die Session, um bei eingeloggten Admins die Nav anzuzeigen.
// Auf der Login-Seite gibt es (noch) keine Session → Nav wird nicht gerendert.
const session = await getSession();
return (
<div className="min-h-screen bg-parchment flex flex-col">
{session && <AdminNav email={session.email} />}
<div className="flex-1">{children}</div>
</div>
);
}

82
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Input, Label, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/admin/anfragen";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Login fehlgeschlagen.");
}
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Unbekannter Fehler.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-parchment">
<div className="w-full max-w-sm bg-cream border border-ink/10 rounded-sm p-8 shadow-card">
<div className="eyebrow mb-3">Admin</div>
<h1 className="font-display text-3xl mb-8 leading-tight">
Willkommen zurück.
</h1>
<form onSubmit={onSubmit} className="space-y-6">
<div>
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
<div>
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<FieldError message={error ?? undefined} />
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? "Anmelden…" : "Anmelden"}
</Button>
</form>
</div>
</div>
);
}

5
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndex() {
redirect("/admin/anfragen");
}

View File

@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { parseJsonArray } from "@/lib/utils";
import {
ApartmentEditor,
type EditorApartment,
} from "@/components/admin/ApartmentEditor";
export const dynamic = "force-dynamic";
export default async function AdminApartmentsPage() {
const rows = await prisma.apartment.findMany({
orderBy: { createdAt: "asc" },
});
const apartments: EditorApartment[] = rows.map((r) => ({
id: r.id,
slug: r.slug,
name: r.name,
tagline: r.tagline,
shortDescription: r.shortDescription,
description: r.description,
priceFrom: r.priceFrom,
maxGuests: r.maxGuests,
bedrooms: r.bedrooms,
sizeSqm: r.sizeSqm,
features: parseJsonArray<string>(r.features),
images: parseJsonArray<string>(r.images),
airbnbUrl: r.airbnbUrl ?? null,
bookingUrl: r.bookingUrl ?? null,
published: r.published,
}));
return (
<div className="container py-10 md:py-14">
<div className="mb-10">
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">Wohnungen</h1>
<p className="text-ink/60 text-sm mt-2">
Basisdaten, Ausstattung und Bilder pflegen.
</p>
</div>
<div className="space-y-8">
{apartments.map((apt) => (
<ApartmentEditor key={apt.id} apartment={apt} />
))}
</div>
</div>
);
}

47
app/anfrage/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { InquiryForm } from "@/components/inquiry/InquiryForm";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Anfrage senden",
description: "Senden Sie eine unverbindliche Anfrage für eine unserer Ferienwohnungen.",
};
export default async function InquiryPage({
searchParams,
}: {
searchParams: { wohnung?: string; arrival?: string; departure?: string };
}) {
const apartments = await prisma.apartment.findMany({
where: { published: true },
select: { slug: true, name: true },
orderBy: { createdAt: "asc" },
});
return (
<div className="py-12 md:py-20">
<div className="container max-w-3xl">
<div className="mb-14">
<div className="eyebrow mb-4">Anfrage</div>
<h1 className="font-display text-display-lg leading-[0.98]">
Erzählen Sie uns,<br />
<span className="italic text-moss-600">wann Sie kommen möchten.</span>
</h1>
<p className="mt-6 text-ink/70 max-w-xl leading-relaxed">
Ein kurzes Formular den Rest klären wir persönlich. Wir antworten
in der Regel innerhalb von 24 Stunden.
</p>
</div>
<InquiryForm
apartments={apartments}
defaultSlug={searchParams.wohnung}
defaultArrival={searchParams.arrival}
defaultDeparture={searchParams.departure}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apartmentUpdateSchema } from "@/lib/validations";
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = apartmentUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const data = parsed.data;
try {
const updated = await prisma.apartment.update({
where: { id: params.id },
data: {
name: data.name,
tagline: data.tagline,
shortDescription: data.shortDescription,
description: data.description,
priceFrom: data.priceFrom,
maxGuests: data.maxGuests,
bedrooms: data.bedrooms,
sizeSqm: data.sizeSqm,
features: JSON.stringify(data.features),
images: JSON.stringify(data.images),
airbnbUrl: data.airbnbUrl || null,
bookingUrl: data.bookingUrl || null,
published: data.published,
},
});
return NextResponse.json({ ok: true, apartment: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.block.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { blockSchema } from "@/lib/validations";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = blockSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { apartmentId, startDate, endDate, note, reason } = parsed.data;
const apt = await prisma.apartment.findUnique({ where: { id: apartmentId } });
if (!apt) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const block = await prisma.block.create({
data: {
apartmentId,
startDate: new Date(startDate),
endDate: new Date(endDate),
note: note || null,
reason,
source: "manual",
},
});
return NextResponse.json({ ok: true, block }, { status: 201 });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
const patchSchema = z.object({
status: z.enum(["new", "read", "confirmed", "declined", "archived"]),
});
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
try {
const updated = await prisma.inquiry.update({
where: { id: params.id },
data: { status: parsed.data.status },
});
return NextResponse.json({ ok: true, inquiry: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.inquiry.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { loginSchema } from "@/lib/validations";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
const { email, password } = parsed.data;
const admin = await prisma.admin.findUnique({ where: { email } });
if (!admin) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
await createSession({ sub: admin.id, email: admin.email });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSession } from "@/lib/auth";
export async function POST() {
clearSession();
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { z } from "zod";
const bodySchema = z.object({
apartmentId: z.string().min(1),
icalUrl: z.string().url(),
source: z.enum(["airbnb", "booking"]),
});
/** Minimal iCal parser for Airbnb / Booking.com VCALENDAR feeds. */
function parseIcal(text: string) {
// Unfold continuation lines (RFC 5545 §3.1)
const unfolded = text.replace(/\r?\n[ \t]/g, "");
const lines = unfolded.split(/\r?\n/);
const events: Array<{ uid: string; start: Date; end: Date }> = [];
let inEvent = false;
let uid = "";
let start: Date | null = null;
let end: Date | null = null;
for (const line of lines) {
if (line === "BEGIN:VEVENT") {
inEvent = true;
uid = "";
start = null;
end = null;
continue;
}
if (line === "END:VEVENT") {
inEvent = false;
if (uid && start && end && end > start) {
events.push({ uid, start, end });
}
continue;
}
if (!inEvent) continue;
// Split on first colon, ignoring property params like ;VALUE=DATE
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const propFull = line.slice(0, colonIdx);
const value = line.slice(colonIdx + 1).trim();
const prop = propFull.split(";")[0].toUpperCase();
if (prop === "DTSTART" || prop === "DTEND") {
const parsed = parseIcalDate(value);
if (parsed) {
if (prop === "DTSTART") start = parsed;
else end = parsed;
}
} else if (prop === "UID") {
uid = value;
}
}
return events;
}
function parseIcalDate(value: string): Date | null {
// DATE: YYYYMMDD
// DATETIME: YYYYMMDDTHHMMSSz or YYYYMMDDTHHMMSSZz
const digits = value.replace(/[TZ]/g, "").slice(0, 8);
if (digits.length < 8) return null;
const y = parseInt(digits.slice(0, 4));
const m = parseInt(digits.slice(4, 6)) - 1;
const d = parseInt(digits.slice(6, 8));
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
return new Date(Date.UTC(y, m, d));
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
const { apartmentId, icalUrl, source } = parsed.data;
// Verify apartment exists
const apartment = await prisma.apartment.findUnique({ where: { id: apartmentId } });
if (!apartment) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
// Fetch the iCal feed
let icalText: string;
try {
const res = await fetch(icalUrl, {
headers: { "User-Agent": "Spreewaldzeit-CalSync/1.0" },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
icalText = await res.text();
} catch (err) {
return NextResponse.json(
{ error: `Kalender konnte nicht geladen werden: ${err instanceof Error ? err.message : "Unbekannt"}` },
{ status: 502 }
);
}
const events = parseIcal(icalText);
// Delete all previous blocks from this source for this apartment
const deleted = await prisma.block.deleteMany({
where: { apartmentId, source },
});
// Insert new blocks
if (events.length > 0) {
await prisma.block.createMany({
data: events.map((e) => ({
apartmentId,
startDate: e.start,
endDate: e.end,
reason: "booking",
source,
note: `iCal-Import (${source}) · UID: ${e.uid.slice(0, 40)}`,
})),
});
}
return NextResponse.json({
ok: true,
deleted: deleted.count,
imported: events.length,
});
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
// Wir geben nur zukünftige & heute aktive Blöcke zurück.
const today = new Date();
today.setHours(0, 0, 0, 0);
const blocks = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
endDate: { gte: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
return NextResponse.json(
{
blocks: blocks.map((b) => ({
start: b.startDate.toISOString(),
end: b.endDate.toISOString(),
})),
},
{
headers: { "Cache-Control": "no-store" },
}
);
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Period {
start: string;
end: string;
nights: number;
}
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const horizon = new Date(today);
horizon.setDate(horizon.getDate() + 180);
// Fetch all blocks that overlap the window
const rows = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
startDate: { lt: horizon },
endDate: { gt: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
const blocks = rows.map((b) => ({
from: new Date(b.startDate),
to: new Date(b.endDate),
}));
// Walk forward from tomorrow, find free 7-night windows
const DEFAULT_NIGHTS = 7;
const MAX_SUGGESTIONS = 8;
const suggestions: Period[] = [];
const cursor = new Date(today);
cursor.setDate(cursor.getDate() + 1); // start from tomorrow
while (cursor < horizon && suggestions.length < MAX_SUGGESTIONS) {
const tripEnd = new Date(cursor);
tripEnd.setDate(tripEnd.getDate() + DEFAULT_NIGHTS);
if (tripEnd > horizon) break;
// Find any block that overlaps [cursor, tripEnd)
const overlap = blocks.find((b) => cursor < b.to && tripEnd > b.from);
if (!overlap) {
suggestions.push({
start: cursor.toISOString().slice(0, 10),
end: tripEnd.toISOString().slice(0, 10),
nights: DEFAULT_NIGHTS,
});
// Jump forward: end of this trip + small gap
cursor.setDate(cursor.getDate() + DEFAULT_NIGHTS + 7);
} else {
// Jump past the blocking block
cursor.setTime(overlap.to.getTime());
cursor.setDate(cursor.getDate() + 1);
}
}
return NextResponse.json({ periods: suggestions }, {
headers: { "Cache-Control": "no-store" },
});
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { inquirySchema } from "@/lib/validations";
import { sendInquiryMails } from "@/lib/email";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const parsed = inquirySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Bitte prüfen Sie Ihre Eingaben.",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Honeypot: wenn `website` gefüllt ist, ist es vermutlich ein Bot.
if (parsed.data.website) {
return NextResponse.json({ ok: true }, { status: 200 });
}
const { apartmentSlug, arrival, departure, guests, name, email, phone, message } =
parsed.data;
const apartment = await prisma.apartment.findUnique({
where: { slug: apartmentSlug },
});
if (!apartment) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const arrivalDate = new Date(arrival);
const departureDate = new Date(departure);
if (guests > apartment.maxGuests) {
return NextResponse.json(
{ error: `Für diese Wohnung sind maximal ${apartment.maxGuests} Personen möglich.` },
{ status: 400 }
);
}
// Hinweis: wir blockieren eine Anfrage NICHT, nur weil die Zeit schon belegt ist —
// der Gast bekommt ggf. eine Absage, und der Vermieter sieht alles im Admin.
// Aber wir markieren im Memo-Feld nichts — Vermieter entscheidet.
const inquiry = await prisma.inquiry.create({
data: {
apartmentId: apartment.id,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone: phone || null,
message: message || "",
status: "new",
},
});
// Mails senden — Fehler dürfen den Erfolg nicht verhindern
try {
await sendInquiryMails({
apartmentName: apartment.name,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone,
message,
inquiryId: inquiry.id,
});
} catch (err) {
console.error("Mailversand fehlgeschlagen:", err);
}
return NextResponse.json({ ok: true, id: inquiry.id }, { status: 201 });
}

69
app/datenschutz/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Datenschutz",
robots: { index: false, follow: true },
};
export default function DatenschutzPage() {
return (
<div className="py-16 md:py-24">
<div className="container max-w-3xl">
<div className="eyebrow mb-4">Rechtliches</div>
<h1 className="font-display text-display-lg mb-10 leading-[1.02]">
Datenschutz
</h1>
<div className="max-w-none text-ink/85 space-y-6 leading-relaxed">
<p>
Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Nachfolgend
informieren wir Sie über die Verarbeitung Ihrer Daten auf dieser Website.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">1. Verantwortlich</h2>
<p>
Familie Musterfrau<br />
Hauptstraße 12, 03222 Lübbenau/Spreewald<br />
E-Mail: <a href="mailto:hallo@spreewaldzeit.de" className="link-underline">hallo@spreewaldzeit.de</a>
</p>
<h2 className="font-display text-2xl mt-10 mb-3">2. Anfrageformular</h2>
<p>
Wenn Sie uns über das Formular eine Anfrage senden, verarbeiten wir die von
Ihnen angegebenen Daten (Name, E-Mail, optional Telefon, Reisedaten,
Nachricht) zum Zweck der Bearbeitung Ihrer Anfrage auf Grundlage von
Art. 6 Abs. 1 lit. b DSGVO. Die Daten werden bei uns gespeichert und nach
Abschluss der Anfrage bzw. nach den steuerrechtlichen Aufbewahrungsfristen
gelöscht.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">3. Server-Logs</h2>
<p>
Beim Aufruf der Website werden technisch notwendige Daten (IP-Adresse,
Zeitpunkt, Browser) temporär verarbeitet (Art. 6 Abs. 1 lit. f DSGVO).
Diese Daten werden nicht mit anderen Datenquellen zusammengeführt.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">4. Cookies & Tracking</h2>
<p>
Diese Website setzt keine Marketing- oder Tracking-Cookies ein. Es werden
lediglich technisch notwendige Cookies (Session) genutzt, soweit Sie sich
als Administrator anmelden.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">5. Ihre Rechte</h2>
<p>
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung
der Verarbeitung, Widerspruch und Datenübertragbarkeit. Wenden Sie sich
hierfür an die oben genannte Adresse. Zudem können Sie sich bei einer
Datenschutzaufsichtsbehörde beschweren.
</p>
<p className="text-xs text-ink/50 pt-10">
Stand: {new Date().toLocaleDateString("de-DE")}. Diese Datenschutzerklärung
ist ein Platzhalter bitte vor Veröffentlichung rechtlich prüfen lassen.
</p>
</div>
</div>
</div>
);
}

146
app/globals.css Normal file
View File

@@ -0,0 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* -----------------------------------------------------------
Basis-Typografie & Grundfarben
----------------------------------------------------------- */
@layer base {
:root {
--color-bg: #f5f1e8;
--color-bg-soft: #fbf9f4;
--color-ink: #1c2620;
--color-moss: #5a6b4f;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-figtree), ui-sans-serif, system-ui, sans-serif;
font-feature-settings: "ss01", "ss02";
font-size: 1.0625rem; /* 17px base */
}
/* Subtle paper texture — desktop only (fixed bg repaints on mobile scroll) */
@media (min-width: 768px) {
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
background-image: radial-gradient(
rgba(28, 38, 32, 0.025) 1px,
transparent 1px
);
background-size: 3px 3px;
}
}
h1, h2, h3, h4 {
font-family: var(--font-fraunces), Georgia, serif;
font-weight: 400;
letter-spacing: -0.02em;
}
::selection {
background: #5a6b4f;
color: #f5f1e8;
}
:focus-visible {
outline: 2px solid #5a6b4f;
outline-offset: 2px;
border-radius: 2px;
}
}
/* -----------------------------------------------------------
Utility-Klassen
----------------------------------------------------------- */
@layer components {
.eyebrow {
@apply text-xs uppercase tracking-[0.22em] text-moss-600 font-medium;
}
.link-underline {
@apply relative inline-block;
background-image: linear-gradient(currentColor, currentColor);
background-position: 0% 100%;
background-repeat: no-repeat;
background-size: 100% 1px;
transition: background-size 0.3s ease;
}
.link-underline:hover {
background-size: 0% 1px;
}
.hairline {
@apply block h-px w-full bg-ink/10;
}
/* Platform external link button */
.platform-link {
@apply flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm text-sm transition-all duration-200;
}
.platform-link:hover {
@apply border-ink/25 bg-parchment;
}
}
/* -----------------------------------------------------------
react-day-picker v9 — palette overrides
----------------------------------------------------------- */
.rdp-root {
--rdp-accent-color: #5a6b4f;
--rdp-accent-background-color: rgba(90, 107, 79, 0.1);
--rdp-today-color: #5a6b4f;
--rdp-range_start-date-background-color: #5a6b4f;
--rdp-range_end-date-background-color: #5a6b4f;
--rdp-range_middle-background-color: rgba(90, 107, 79, 0.1);
--rdp-range_start-color: #fbf9f4;
--rdp-range_end-color: #fbf9f4;
--rdp-disabled-opacity: 0.45;
--rdp-day-height: 44px;
--rdp-day-width: 44px;
--rdp-day_button-height: 40px;
--rdp-day_button-width: 40px;
margin: 0;
font-family: var(--font-figtree), ui-sans-serif, sans-serif;
font-size: 0.9rem;
}
.rdp-selected .rdp-day_button {
background-color: #5a6b4f;
color: #fbf9f4;
border-color: #5a6b4f;
}
.rdp-disabled:not(.rdp-selected) .rdp-day_button {
text-decoration: line-through;
color: #a9a391;
}
.rdp-caption_label {
font-family: var(--font-fraunces), Georgia, serif;
font-weight: 400;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.rdp-button_previous,
.rdp-button_next {
transition: background-color 0.2s ease, opacity 0.2s ease;
border-radius: 6px;
}
.rdp-button_previous:hover,
.rdp-button_next:hover {
background-color: rgba(90, 107, 79, 0.1);
}
.rdp-day_button:hover:not(:disabled) {
background-color: rgba(90, 107, 79, 0.08);
}

60
app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Impressum",
robots: { index: false, follow: true },
};
export default function ImpressumPage() {
return (
<div className="py-16 md:py-24">
<div className="container max-w-3xl">
<div className="eyebrow mb-4">Rechtliches</div>
<h1 className="font-display text-display-lg mb-10 leading-[1.02]">
Impressum
</h1>
<div className="text-ink/85 space-y-6 leading-relaxed">
<h2 className="font-display text-2xl mt-6 mb-2">Angaben gemäß § 5 TMG</h2>
<p>
Familie Musterfrau<br />
Hauptstraße 12<br />
03222 Lübbenau/Spreewald<br />
Deutschland
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Kontakt</h2>
<p>
Telefon: +49 (0)3542 000000<br />
E-Mail: <a href="mailto:hallo@spreewaldzeit.de" className="link-underline">hallo@spreewaldzeit.de</a>
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Umsatzsteuer-ID</h2>
<p>
Gemäß §27 a Umsatzsteuergesetz:<br />
DE000000000
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Streitschlichtung</h2>
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
bereit:{" "}
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noopener noreferrer"
className="link-underline"
>
https://ec.europa.eu/consumers/odr/
</a>
. Wir sind nicht bereit oder verpflichtet, an einem Streitbeilegungsverfahren
vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
<p className="text-xs text-ink/50 pt-10">
Platzhalter bitte vor Veröffentlichung anpassen.
</p>
</div>
</div>
</div>
);
}

53
app/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { Fraunces, Figtree } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
});
const figtree = Figtree({
subsets: ["latin"],
display: "swap",
variable: "--font-figtree",
});
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"),
title: {
default: "Spreewaldzeit Zwei Ferienwohnungen am Fließ",
template: "%s · Spreewaldzeit",
},
description:
"Zwei private Ferienwohnungen im Spreewald — ruhig, mit viel Holz, Wasser vor der Tür und Platz zum Durchatmen.",
openGraph: {
title: "Spreewaldzeit",
description:
"Zwei private Ferienwohnungen im Spreewald — ruhig, mit viel Holz, Wasser vor der Tür und Platz zum Durchatmen.",
locale: "de_DE",
type: "website",
},
robots: { index: true, follow: true },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de" className={`${fraunces.variable} ${figtree.variable}`}>
<body className="min-h-screen flex flex-col">
<div className="relative z-10 flex flex-col flex-1">
<Header />
<main className="flex-1 pt-[72px] md:pt-[80px]">{children}</main>
<Footer />
</div>
</body>
</html>
);
}

21
app/not-found.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="container py-24 md:py-32 text-center">
<div className="eyebrow mb-4">404</div>
<h1 className="font-display text-display-lg mb-6 leading-[1.02]">
Hier ist nur <span className="italic text-moss-600">Nebel.</span>
</h1>
<p className="text-ink/70 max-w-md mx-auto">
Die Seite, die Sie suchen, gibt es nicht (mehr).
</p>
<Link
href="/"
className="inline-flex items-center gap-2 mt-10 bg-ink text-parchment px-7 py-3.5 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Zurück zur Startseite
</Link>
</div>
);
}

36
app/page.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { prisma } from "@/lib/db";
import { parseJsonArray } from "@/lib/utils";
import type { Apartment } from "@/types";
import { Hero } from "@/components/home/Hero";
import { About } from "@/components/home/About";
import { ApartmentPreview } from "@/components/home/ApartmentPreview";
import { Location } from "@/components/home/Location";
import { PlacesToVisit } from "@/components/home/PlacesToVisit";
// Daten immer frisch (SQLite, lokal unproblematisch; für Produktion ggf. ISR)
export const dynamic = "force-dynamic";
async function getApartments(): Promise<Apartment[]> {
const rows = await prisma.apartment.findMany({
where: { published: true },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
...r,
features: parseJsonArray<string>(r.features),
images: parseJsonArray<string>(r.images),
}));
}
export default async function HomePage() {
const apartments = await getApartments();
return (
<>
<Hero />
<About />
<ApartmentPreview apartments={apartments} />
<Location />
<PlacesToVisit />
</>
);
}

View File

@@ -0,0 +1,145 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { formatPrice, parseJsonArray } from "@/lib/utils";
import { Gallery } from "@/components/apartment/Gallery";
import { Features } from "@/components/apartment/Features";
import { AvailabilityCalendar } from "@/components/apartment/AvailabilityCalendar";
import { BookingPlatforms } from "@/components/apartment/BookingPlatforms";
import { AvailablePeriods } from "@/components/apartment/AvailablePeriods";
export const dynamic = "force-dynamic";
interface PageProps {
params: { slug: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const apt = await prisma.apartment.findUnique({ where: { slug: params.slug } });
if (!apt) return { title: "Wohnung nicht gefunden" };
return {
title: `${apt.name}${apt.tagline}`,
description: apt.shortDescription,
};
}
export default async function ApartmentDetailPage({ params }: PageProps) {
const row = await prisma.apartment.findUnique({ where: { slug: params.slug } });
if (!row || !row.published) notFound();
const apartment = {
...row,
features: parseJsonArray<string>(row.features),
images: parseJsonArray<string>(row.images),
};
return (
<article className="pt-4 md:pt-8 pb-20">
<div className="container">
{/* Brotkrümel */}
<nav className="mb-8 text-xs text-ink/60">
<Link href="/" className="link-underline">Start</Link>
<span className="mx-2 text-ink/30">/</span>
<Link href="/#wohnungen" className="link-underline">Wohnungen</Link>
<span className="mx-2 text-ink/30">/</span>
<span className="text-ink/80">{apartment.name}</span>
</nav>
{/* Titelblock */}
<header className="grid md:grid-cols-12 gap-8 mb-10 md:mb-16 items-end">
<div className="md:col-span-7">
<div className="eyebrow mb-3">{apartment.tagline}</div>
<h1 className="font-display text-display-lg leading-[0.98]">
{apartment.name}
</h1>
</div>
<dl className="md:col-span-5 grid grid-cols-3 gap-4 border-t md:border-t-0 md:border-l border-ink/10 md:pl-8 pt-6 md:pt-0">
<div>
<dt className="eyebrow mb-1">Gäste</dt>
<dd className="font-display text-xl">bis {apartment.maxGuests}</dd>
</div>
<div>
<dt className="eyebrow mb-1">Größe</dt>
<dd className="font-display text-xl">{apartment.sizeSqm} m²</dd>
</div>
<div>
<dt className="eyebrow mb-1">ab</dt>
<dd className="font-display text-xl">
{formatPrice(apartment.priceFrom)}
<span className="text-xs text-ink/50">/Nacht</span>
</dd>
</div>
</dl>
</header>
{/* Galerie */}
<Gallery images={apartment.images} alt={apartment.name} />
{/* Beschreibung + Sticky-CTA */}
<div className="mt-16 md:mt-20 grid md:grid-cols-12 gap-10 md:gap-16">
<div className="md:col-span-7">
<div className="eyebrow mb-4">Die Wohnung</div>
<h2 className="font-display text-3xl md:text-4xl leading-tight mb-6">
{apartment.shortDescription}
</h2>
<p className="text-ink/80 leading-relaxed whitespace-pre-line">
{apartment.description}
</p>
<Features features={apartment.features} />
<AvailablePeriods slug={apartment.slug} />
<AvailabilityCalendar slug={apartment.slug} />
</div>
{/* Sticky Anfrage-Box */}
<aside className="md:col-span-5 md:col-start-9">
<div className="md:sticky md:top-28 bg-cream rounded-sm p-7 md:p-8 border border-ink/10">
<div className="flex items-baseline justify-between gap-2 mb-5">
<div>
<div className="eyebrow">Ab</div>
<div className="font-display text-3xl">
{formatPrice(apartment.priceFrom)}
</div>
</div>
<div className="text-right text-xs text-ink/55">
pro Nacht<br />inkl. Nebenkosten
</div>
</div>
<BookingPlatforms
airbnbUrl={apartment.airbnbUrl}
bookingUrl={apartment.bookingUrl}
className="mb-5"
/>
{(apartment.airbnbUrl || apartment.bookingUrl) && (
<div className="flex items-center gap-3 my-5">
<span className="flex-1 h-px bg-ink/10" />
<span className="text-[11px] text-ink/40 uppercase tracking-widest">oder direkt</span>
<span className="flex-1 h-px bg-ink/10" />
</div>
)}
<p className="text-sm text-ink/70 leading-relaxed mb-4">
Senden Sie uns Ihren Wunsch­zeitraum wir melden uns in der Regel
innerhalb von 24 Stunden. Ohne Provision.
</p>
<Link
href={`/anfrage?wohnung=${apartment.slug}`}
className="flex items-center justify-center w-full bg-ink text-parchment px-6 py-4 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Anfrage senden
</Link>
<p className="mt-4 text-[11px] text-ink/50 text-center">
Unverbindlich · keine Vorabzahlung · keine Provision
</p>
</div>
</aside>
</div>
</div>
</article>
);
}