Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
99
app/admin/anfragen/page.tsx
Normal file
99
app/admin/anfragen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/admin/kalender/page.tsx
Normal file
41
app/admin/kalender/page.tsx
Normal 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
22
app/admin/layout.tsx
Normal 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
82
app/admin/login/page.tsx
Normal 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
5
app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminIndex() {
|
||||
redirect("/admin/anfragen");
|
||||
}
|
||||
50
app/admin/wohnungen/page.tsx
Normal file
50
app/admin/wohnungen/page.tsx
Normal 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
47
app/anfrage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/api/admin/apartments/[id]/route.ts
Normal file
43
app/api/admin/apartments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
app/api/admin/blocks/[id]/route.ts
Normal file
14
app/api/admin/blocks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
app/api/admin/blocks/route.ts
Normal file
34
app/api/admin/blocks/route.ts
Normal 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 });
|
||||
}
|
||||
40
app/api/admin/inquiries/[id]/route.ts
Normal file
40
app/api/admin/inquiries/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
app/api/admin/login/route.ts
Normal file
33
app/api/admin/login/route.ts
Normal 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 });
|
||||
}
|
||||
7
app/api/admin/logout/route.ts
Normal file
7
app/api/admin/logout/route.ts
Normal 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 });
|
||||
}
|
||||
130
app/api/admin/sync-ical/route.ts
Normal file
130
app/api/admin/sync-ical/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
40
app/api/availability/[slug]/route.ts
Normal file
40
app/api/availability/[slug]/route.ts
Normal 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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
81
app/api/available-periods/[slug]/route.ts
Normal file
81
app/api/available-periods/[slug]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
86
app/api/inquiries/route.ts
Normal file
86
app/api/inquiries/route.ts
Normal 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
69
app/datenschutz/page.tsx
Normal 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
146
app/globals.css
Normal 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
60
app/impressum/page.tsx
Normal 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
53
app/layout.tsx
Normal 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
21
app/not-found.tsx
Normal 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
36
app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
app/wohnungen/[slug]/page.tsx
Normal file
145
app/wohnungen/[slug]/page.tsx
Normal 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 Wunschzeitraum — 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user