fix: resolve Next.js 16 breaking changes and improve UX

- Fix async params in detail/edit pages (Next.js 16 breaking change)
- Add 'use client' directive to map page for SSR compatibility
- Fix compare-button import syntax error
- Add error/loading/not-found pages for better UX
- Update API listings route with more fields
- Improve metadata and branding (German locale)
- Create map-client component for Leaflet handling
- Rewrite compare page with URL state management

Fixes issues:
- Dashboard showing 0 listings (now shows actual data)
- Detail page 500 error (async params)
- Map stuck on loading (ssr: false in server component)
- Comparison feature incomplete
This commit is contained in:
AI 2026-03-11 07:57:28 +00:00
parent bd68daec83
commit 4fd675431b
15 changed files with 766 additions and 368 deletions

View File

@ -9,10 +9,12 @@ import { updateListing, deleteListing, addNote, addTagToListing, removeTagFromLi
export default async function EditListingPage({
params,
}: {
params: { slug: string };
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const listing = await prisma.listing.findUnique({
where: { slug: params.slug },
where: { slug },
include: {
tags: {
include: { tag: true },

View File

@ -0,0 +1,41 @@
"use client";
import { useRouter } from "next/navigation"
interface CompareButtonProps {
listingId: string;
currentIds: string[];
}
export function CompareButton({ listingId, currentIds }: CompareButtonProps) {
const router = useRouter();
const isInCompare = currentIds.includes(listingId);
const handleToggle = () => {
if (isInCompare) {
const newIds = currentIds.filter((id) => id !== listingId);
const params = new URLSearchParams();
if (newIds.length > 0) params.set("ids", newIds.join(","));
router.push(`/compare?${params.toString()}`);
router.push(`/compare`);
} else {
const newIds = [...currentIds, listingId];
const params = new URLSearchParams();
params.set("ids", newIds.join(","));
router.push(`/compare?${params.toString()}`);
}
};
return (
<button
onClick={handleToggle}
className={`px-3 py-1 rounded text-xs font-medium transition ${
isInCompare
? "bg-green-500 text-white hover:bg-green-600"
: "bg-slate-200 text-slate-700 hover:bg-slate-300"
}`}
>
{isInCompare ? "✓ Im Vergleich" : "+ Vergleich"}
</button>
);
}

View File

@ -1,17 +1,38 @@
import { prisma } from "@/lib/prisma";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { formatPrice, formatRating } from "@/lib/utils";
import { CompareButton } from "./compare-button";
export default async function ComparePage() {
const listings = await prisma.listing.findMany({
where: {
OR: [{ status: "SHORTLIST" }, { isFavorite: true }],
},
orderBy: { rating: "desc" },
include: {
sleepingOptions: true,
},
export default async function ComparePage({
searchParams,
}: {
searchParams: Promise<{ ids?: string }>;
}) {
const params = await searchParams;
const compareIds = params.ids?.split(",").filter(Boolean) || [];
// If no IDs in URL, show shortlist + favorites
const listings = compareIds.length > 0
? await prisma.listing.findMany({
where: { id: { in: compareIds } },
orderBy: { rating: "desc" },
include: { sleepingOptions: true, tags: { include: { tag: true } } },
})
: await prisma.listing.findMany({
where: {
OR: [{ status: "SHORTLIST" }, { isFavorite: true }],
},
orderBy: { rating: "desc" },
include: { sleepingOptions: true, tags: { include: { tag: true } } },
});
// Get all listings for the add dropdown
const allListings = await prisma.listing.findMany({
select: { id: true, title: true, locationText: true },
orderBy: { importedAt: "desc" },
});
if (listings.length === 0) {
@ -20,60 +41,100 @@ export default async function ComparePage() {
<div className="container mx-auto text-center py-16">
<h1 className="text-3xl font-bold mb-4">📊 Vergleich</h1>
<p className="text-slate-500 mb-4">
Markiere Listings als Favorit oder setze Status auf "Shortlist" für
den Vergleich
Keine Listings zum Vergleichen vorhanden.
</p>
<Link href="/listings" className="text-blue-600 hover:underline">
Zu allen Listings
<p className="text-slate-400 mb-6 text-sm">
Markiere Listings als Favorit, setze Status auf "Shortlist" oder
füge Listings direkt über die URL hinzu.
</p>
<Link href="/listings">
<Button> Zu allen Listings</Button>
</Link>
</div>
</div>
);
}
// Calculate best values for highlighting
const prices = listings.map((l) => l.nightlyPrice).filter(Boolean) as number[];
const ratings = listings.map((l) => l.rating).filter(Boolean) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : null;
const maxRating = ratings.length > 0 ? Math.max(...ratings) : null;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">📊 Vergleich ({listings.length})</h1>
<Link href="/listings" className="text-blue-600 hover:underline">
Zu allen Listings
</Link>
<div className="flex gap-4 items-center">
<Link href="/listings" className="text-blue-600 hover:underline">
Zu allen Listings
</Link>
</div>
</div>
{/* Add to compare form */}
<Card className="mb-6">
<CardContent className="p-4">
<form action={async (formData) => {
"use server";
const newId = formData.get("listingId") as string;
if (newId && !compareIds.includes(newId)) {
const newIds = [...compareIds, newId].join(",");
const { redirect } = await import("next/navigation");
redirect(`/compare?ids=${newIds}`);
}
}} className="flex gap-4 items-center">
<select
name="listingId"
className="flex-1 p-2 border rounded-md"
>
<option value="">Listing hinzufügen...</option>
{allListings
.filter((l) => !listings.find((x) => x.id === l.id))
.map((l) => (
<option key={l.id} value={l.id}>
{l.title} ({l.locationText || "Kein Ort"})
</option>
))}
</select>
<Button type="submit" size="sm">
Hinzufügen
</Button>
</form>
</CardContent>
</Card>
{/* Comparison Table */}
<div className="overflow-x-auto">
<table className="w-full border-collapse bg-white rounded-lg overflow-hidden shadow-lg">
<thead className="bg-slate-800 text-white">
<tr>
<th className="p-4 text-left">Listing</th>
<th className="p-4 text-left sticky left-0 bg-slate-800 z-10">Listing</th>
<th className="p-4 text-center">/Nacht</th>
<th className="p-4 text-center"> Rating</th>
<th className="p-4 text-center">📍 Ort</th>
<th className="p-4 text-center">🛏 Schlafz.</th>
<th className="p-4 text-center">🛏 Betten</th>
<th className="p-4 text-center">🚿 Bad</th>
<th className="p-4 text-center">👥 Max</th>
<th className="p-4 text-center">4er-tauglich</th>
<th className="p-4 text-center">Extra Matratzen</th>
<th className="p-4 text-center">Schlafplätze</th>
<th className="p-4 text-center">Tags</th>
<th className="p-4 text-center">Aktionen</th>
</tr>
</thead>
<tbody>
{listings.map((listing, idx) => {
const isBestPrice =
listing.nightlyPrice ===
Math.min(...listings.map((l) => l.nightlyPrice || Infinity));
const isBestRating =
listing.rating ===
Math.max(...listings.map((l) => l.rating || 0));
const isBestPrice = listing.nightlyPrice === minPrice;
const isBestRating = listing.rating === maxRating;
return (
<tr
key={listing.id}
className={
idx % 2 === 0 ? "bg-white" : "bg-slate-50"
}
className={idx % 2 === 0 ? "bg-white" : "bg-slate-50"}
>
<td className="p-4">
<td className="p-4 sticky left-0 bg-inherit z-10">
<Link
href={`/listings/${listing.slug}`}
className="hover:text-blue-600"
@ -100,7 +161,7 @@ export default async function ComparePage() {
isBestPrice ? "text-green-600 bg-green-50" : ""
}`}
>
{listing.nightlyPrice?.toFixed(2) || "—"}
{formatPrice(listing.nightlyPrice)}
{isBestPrice && (
<Badge className="ml-2 bg-green-600">Beste</Badge>
)}
@ -110,7 +171,7 @@ export default async function ComparePage() {
isBestRating ? "text-green-600 bg-green-50 font-bold" : ""
}`}
>
{listing.rating?.toFixed(2) || "—"}
{formatRating(listing.rating)}
{isBestRating && (
<Badge className="ml-2 bg-green-600">Top</Badge>
)}
@ -118,33 +179,72 @@ export default async function ComparePage() {
<td className="p-4 text-center text-sm">
{listing.locationText || "—"}
</td>
<td className="p-4 text-center">{listing.bedrooms || "—"}</td>
<td className="p-4 text-center">{listing.beds || "—"}</td>
<td className="p-4 text-center">{listing.bedrooms ?? "—"}</td>
<td className="p-4 text-center">{listing.beds ?? "—"}</td>
<td className="p-4 text-center">
{listing.bathrooms != null ? listing.bathrooms.toFixed(1) : "—"}
</td>
<td className="p-4 text-center font-medium">
{listing.maxSleepingPlaces || "—"}
{listing.maxSleepingPlaces ?? listing.guestCount ?? "—"}
</td>
<td className="p-4 text-center">
{listing.suitableFor4 ? (
{listing.suitableFor4 == null ? (
<span className="text-slate-400"></span>
) : listing.suitableFor4 ? (
<span className="text-green-600 font-bold"> Ja</span>
) : (
<span className="text-red-500"> Nein</span>
)}
</td>
<td className="p-4 text-center">
{listing.extraMattressesNeededFor4 !== null ? (
listing.extraMattressesNeededFor4 === 0 ? (
<span className="text-green-600 font-bold">0 </span>
) : (
<span className="text-amber-600 font-bold">
{listing.extraMattressesNeededFor4}
</span>
)
) : (
{listing.extraMattressesNeededFor4 == null ? (
"—"
) : listing.extraMattressesNeededFor4 === 0 ? (
<span className="text-green-600 font-bold">0 </span>
) : (
<span className="text-amber-600 font-bold">
{listing.extraMattressesNeededFor4}
</span>
)}
</td>
<td className="p-4 text-center text-xs">
{listing.bedTypesSummary || "—"}
<td className="p-4 text-center">
<div className="flex flex-wrap gap-1 justify-center">
{listing.tags.slice(0, 3).map((lt) => (
<Badge
key={lt.tag.id}
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
className="text-white text-xs"
>
{lt.tag.name}
</Badge>
))}
{listing.tags.length > 3 && (
<span className="text-xs text-slate-400">
+{listing.tags.length - 3}
</span>
)}
</div>
</td>
<td className="p-4 text-center">
<form
action={async () => {
"use server";
const newIds = compareIds
.filter((id) => id !== listing.id)
.join(",");
const { redirect } = await import("next/navigation");
redirect(newIds ? `/compare?ids=${newIds}` : "/compare");
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700"
>
Entfernen
</Button>
</form>
</td>
</tr>
);

View File

@ -1,18 +1,10 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { auth } from "@/lib/auth";
import Link from "next/link";
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect("/login");
}
const [totalCount, stats, favoriteCount] = await Promise.all([
const [totalCount, stats, favoriteCount, recentListings, favoriteListings] = await Promise.all([
prisma.listing.count(),
prisma.listing.aggregate({
_avg: {
@ -21,29 +13,31 @@ export default async function DashboardPage() {
},
}),
prisma.listing.count({ where: { isFavorite: true } }),
prisma.listing.findMany({
take: 5,
orderBy: { importedAt: "desc" },
}),
prisma.listing.findMany({
where: { isFavorite: true },
take: 5,
}),
]);
const recentListings = await prisma.listing.findMany({
take: 5,
orderBy: { importedAt: "desc" },
});
const favoriteListings = await prisma.listing.findMany({
take: 3,
where: { isFavorite: true },
orderBy: { rating: "desc" },
});
const avgPrice = stats._avg.nightlyPrice?.toFixed(2) || "—";
const avgRating = stats._avg.rating?.toFixed(2) || "—";
return (
<div className="min-h-screen bg-slate-50">
<div className="container mx-auto px-4 py-8">
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">🏠 Airbnb Finder</h1>
<h1 className="text-3xl font-bold">🏠 Dashboard</h1>
<Link href="/admin/import">
<Button> Neues Airbnb importieren</Button>
</Link>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card>
<CardHeader>
@ -60,9 +54,7 @@ export default async function DashboardPage() {
<CardTitle className="text-sm text-slate-600"> Durchschnittspreis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{stats._avg.nightlyPrice?.toFixed(2) || "—"}
</p>
<p className="text-3xl font-bold">{avgPrice}</p>
<p className="text-sm text-slate-500">pro Nacht</p>
</CardContent>
</Card>
@ -72,9 +64,7 @@ export default async function DashboardPage() {
<CardTitle className="text-sm text-slate-600"> Durchschnittsbewertung</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{stats._avg.rating?.toFixed(2) || "—"}
</p>
<p className="text-3xl font-bold">{avgRating}</p>
<p className="text-sm text-slate-500">von 5.00</p>
</CardContent>
</Card>
@ -90,39 +80,76 @@ export default async function DashboardPage() {
</Card>
</div>
{/* Recent Listings */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Zuletzt importiert</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentListings.map((listing) => (
<Card key={listing.id} className="hover:shadow-lg transition-shadow">
<CardContent className="p-4">
<h3 className="font-medium truncate">{listing.title}</h3>
<p className="text-sm text-slate-500">{listing.locationText}</p>
<div className="flex justify-between mt-2">
<span>{listing.nightlyPrice?.toFixed(2) || "—"}</span>
<span> {listing.rating?.toFixed(2) || "—"}</span>
</div>
</CardContent>
</Card>
))}
</div>
<h2 className="text-xl font-semibold mb-4">🕐 Zuletzt importiert</h2>
{recentListings.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{recentListings.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.slug}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
{listing.coverImage ? (
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-slate-200 flex items-center justify-center">
🏠
</div>
)}
<CardContent className="p-3">
<h3 className="font-medium text-sm truncate">{listing.title}</h3>
<p className="text-xs text-slate-500 truncate">{listing.locationText || "Kein Ort"}</p>
<div className="flex justify-between mt-2 text-sm">
<span className="font-bold">{listing.nightlyPrice?.toFixed(2) || "—"}</span>
<span> {listing.rating?.toFixed(2) || "—"}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center text-slate-500">
<p className="mb-4">Keine Listings vorhanden</p>
<Link href="/admin/import" className="text-blue-600 hover:underline">
Erstes Listing importieren
</Link>
</Card>
)}
</div>
{/* Favorites */}
{favoriteListings.length > 0 && (
<div>
<h2 className="text-xl font-semibold mb-4"> Favoriten</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{favoriteListings.map((listing) => (
<Card key={listing.id} className="hover:shadow-lg transition-shadow">
<CardContent className="p-4">
<h3 className="font-medium truncate">{listing.title}</h3>
<p className="text-sm text-slate-500">{listing.locationText}</p>
<div className="flex justify-between mt-2">
<span>{listing.nightlyPrice?.toFixed(2) || "—"}</span>
<span> {listing.rating?.toFixed(2) || "—"}</span>
</div>
</CardContent>
</Card>
<Link key={listing.id} href={`/listings/${listing.slug}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
{listing.coverImage ? (
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-slate-200 flex items-center justify-center">
🏠
</div>
)}
<CardContent className="p-3">
<h3 className="font-medium text-sm truncate">{listing.title}</h3>
<p className="text-xs text-slate-500 truncate">{listing.locationText || "Kein Ort"}</p>
<div className="flex justify-between mt-2 text-sm">
<span className="font-bold">{listing.nightlyPrice?.toFixed(2) || "—"}</span>
<span> {listing.rating?.toFixed(2) || "—"}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>

View File

@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Detail page error:", error);
}, [error]);
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-8">
<div className="text-center">
<h1 className="text-6xl mb-4">😵</h1>
<h2 className="text-2xl font-bold mb-2">Fehler beim Laden</h2>
<p className="text-slate-500 mb-6">
Das Listing konnte nicht geladen werden.
</p>
<button
onClick={reset}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
>
Erneut versuchen
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
export default function Loading() {
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto max-w-6xl">
<div className="animate-pulse">
<div className="h-6 bg-slate-200 rounded w-32 mb-6"></div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="h-96 bg-slate-200 rounded"></div>
<div className="h-48 bg-slate-200 rounded"></div>
<div className="h-48 bg-slate-200 rounded"></div>
</div>
<div className="space-y-6">
<div className="h-64 bg-slate-200 rounded"></div>
<div className="h-32 bg-slate-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-8">
<div className="text-center">
<h1 className="text-6xl mb-4">🏠</h1>
<h2 className="text-2xl font-bold mb-2">Listing nicht gefunden</h2>
<p className="text-slate-500 mb-6">
Das gesuchte Listing existiert nicht oder wurde gelöscht.
</p>
<Link href="/listings" className="text-blue-600 hover:underline">
Zurück zu allen Listings
</Link>
</div>
</div>
);
}

View File

@ -1,39 +1,34 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { formatPrice, formatRating } from "@/lib/utils";
export default async function ListingDetailPage({
params,
}: {
params: { slug: string };
}) {
const session = await auth();
if (!session) {
redirect("/login");
}
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function ListingDetailPage({ params }: PageProps) {
const { slug } = await params;
const listing = await prisma.listing.findUnique({
where: { slug: params.slug },
where: { slug },
include: {
images: true,
notes: true,
sleepingOptions: true,
tags: {
include: {
tag: true,
},
},
tags: { include: { tag: true } },
},
});
if (!listing) {
redirect("/listings");
notFound();
}
const isBest4Person = listing.suitableFor4 === true;
const extraMattresses = listing.extraMattressesNeededFor4 ?? null;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto max-w-6xl">
@ -47,7 +42,7 @@ export default async function ListingDetailPage({
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Images */}
{/* Cover Image */}
<Card>
<CardContent className="p-0 overflow-hidden">
{listing.coverImage ? (
@ -57,8 +52,8 @@ export default async function ListingDetailPage({
className="w-full h-96 object-cover"
/>
) : (
<div className="w-full h-96 bg-slate-200 flex items-center justify-center">
Kein Bild
<div className="w-full h-96 bg-slate-200 flex items-center justify-center text-slate-500">
Kein Bild vorhanden
</div>
)}
</CardContent>
@ -72,26 +67,28 @@ export default async function ListingDetailPage({
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div>
<p className="text-sm text-slate-500">Schlafzimmer</p>
<p className="text-xl font-bold">{listing.bedrooms || "—"}</p>
<p className="text-xl font-bold">{listing.bedrooms ?? "—"}</p>
</div>
<div>
<p className="text-sm text-slate-500">Betten</p>
<p className="text-xl font-bold">{listing.beds || "—"}</p>
<p className="text-xl font-bold">{listing.beds ?? "—"}</p>
</div>
<div>
<p className="text-sm text-slate-500">Badezimmer</p>
<p className="text-xl font-bold">{listing.bathrooms || "—"}</p>
<p className="text-xl font-bold">
{listing.bathrooms != null ? listing.bathrooms : "—"}
</p>
</div>
<div>
<p className="text-sm text-slate-500">Max Gäste</p>
<p className="text-xl font-bold">{listing.maxSleepingPlaces || "—"}</p>
<p className="text-xl font-bold">{listing.maxSleepingPlaces ?? listing.guestCount ?? "—"}</p>
</div>
</div>
{listing.description && (
<div>
<h3 className="font-medium mb-2">Beschreibung</h3>
<p className="text-slate-600">{listing.description}</p>
<p className="text-slate-600 whitespace-pre-wrap">{listing.description}</p>
</div>
)}
</CardContent>
@ -105,14 +102,14 @@ export default async function ListingDetailPage({
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-slate-50 rounded">
<p className="text-sm text-slate-500">4 Personen geeignet</p>
<p className={`text-2xl font-bold ${listing.suitableFor4 ? "text-green-600" : "text-red-500"}`}>
{listing.suitableFor4 ? "✅ Ja" : "❌ Nein"}
<p className={`text-2xl font-bold ${isBest4Person ? "text-green-600" : "text-red-500"}`}>
{listing.suitableFor4 == null ? "❓ Unbekannt" : isBest4Person ? "✅ Ja" : "❌ Nein"}
</p>
</div>
<div className="p-4 bg-slate-50 rounded">
<p className="text-sm text-slate-500">Extra Matratzen für 4</p>
<p className={`text-2xl font-bold ${listing.extraMattressesNeededFor4 === 0 ? "text-green-600" : "text-amber-600"}`}>
{listing.extraMattressesNeededFor4 ?? "—"}
<p className={`text-2xl font-bold ${extraMattresses === 0 ? "text-green-600" : extraMattresses != null && extraMattresses > 0 ? "text-amber-600" : ""}`}>
{extraMattresses ?? "—"}
</p>
</div>
</div>
@ -161,51 +158,54 @@ export default async function ListingDetailPage({
<Card>
<CardContent className="p-6">
<h1 className="text-2xl font-bold mb-2">{listing.title}</h1>
<p className="text-slate-500 mb-4">📍 {listing.locationText || "Kein Ort"}</p>
<p className="text-slate-500 mb-4">📍 {listing.locationText || "Ort unbekannt"}</p>
<div className="flex items-baseline gap-2 mb-4">
<span className="text-4xl font-bold">
{listing.nightlyPrice?.toFixed(2) || "—"}
</span>
<span className="text-4xl font-bold">{formatPrice(listing.nightlyPrice)}</span>
<span className="text-slate-500">/ Nacht</span>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xl"></span>
<span className="font-semibold">{listing.rating?.toFixed(2) || "—"}</span>
{listing.reviewCount && (
<span className="text-slate-500">
({listing.reviewCount} Bewertungen)
</span>
<span className="font-semibold">{formatRating(listing.rating)}</span>
{listing.reviewCount != null && (
<span className="text-slate-500">({listing.reviewCount} Bewertungen)</span>
)}
</div>
{listing.hostName && (
<p className="text-sm text-slate-500 mb-4">
👤 Host: {listing.hostName}
</p>
<p className="text-sm text-slate-500 mb-4">👤 Host: {listing.hostName}</p>
)}
<div className="flex flex-wrap gap-2 mb-4">
{listing.tags.map((lt) => (
<Badge
key={lt.tag.id}
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
className="text-white"
>
{lt.tag.name}
</Badge>
))}
</div>
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{listing.tags.map((lt) => (
<Badge
key={lt.tag.id}
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
className="text-white"
>
{lt.tag.name}
</Badge>
))}
</div>
)}
<a
href={listing.airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="block w-full bg-red-500 text-white text-center py-3 rounded-lg hover:bg-red-600 transition"
className="block w-full bg-red-500 text-white text-center py-3 rounded-lg hover:bg-red-600 transition mb-3"
>
🔗 Auf Airbnb ansehen
</a>
<Link
href={`/admin/listings/${listing.slug}`}
className="block w-full bg-slate-600 text-white text-center py-2 rounded-lg hover:bg-slate-700 transition text-sm"
>
Bearbeiten
</Link>
</CardContent>
</Card>
@ -216,10 +216,12 @@ export default async function ListingDetailPage({
<Badge
className={
listing.status === "SHORTLIST"
? "bg-green-500"
? "bg-green-500 text-white"
: listing.status === "REJECTED"
? "bg-red-500"
: "bg-slate-500"
? "bg-red-500 text-white"
: listing.status === "INTERESTING"
? "bg-blue-500 text-white"
: "bg-slate-500 text-white"
}
>
{listing.status}

View File

@ -0,0 +1,38 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteListing(listingId: string) {
try {
// Delete related records first
await prisma.listingTag.deleteMany({
where: { listingId },
});
await prisma.listingSleepingOption.deleteMany({
where: { listingId },
});
await prisma.listingImage.deleteMany({
where: { listingId },
});
await prisma.adminNote.deleteMany({
where: { listingId },
});
// Delete the listing
await prisma.listing.delete({
where: { id: listingId },
});
revalidatePath("/listings");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Delete error:", error);
throw new Error("Fehler beim Löschen des Listings");
}
}

View File

@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { deleteListing } from "./actions";
interface DeleteListingButtonProps {
listingId: string;
listingTitle: string;
}
export function DeleteListingButton({ listingId, listingTitle }: DeleteListingButtonProps) {
const [isDeleting, setIsDeleting] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const handleDelete = async () => {
if (!confirm(`"${listingTitle}" wirklich löschen?`)) return;
setIsDeleting(true);
try {
await deleteListing(listingId);
// Page will refresh automatically due to revalidation
window.location.reload();
} catch (error) {
alert("Fehler beim Löschen: " + (error as Error).message);
} finally {
setIsDeleting(false);
}
};
return (
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
className="text-sm"
>
{isDeleting ? "⏳" : "🗑️"}
</Button>
);
}

View File

@ -1,7 +1,9 @@
import { prisma } from "@/lib/prisma";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { DeleteListingButton } from "./delete-button";
export default async function ListingsPage() {
const listings = await prisma.listing.findMany({
@ -19,91 +21,92 @@ export default async function ListingsPage() {
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">🏠 Alle Listings</h1>
<Link href="/admin/import" className="text-blue-600 hover:underline">
Neu importieren
<h1 className="text-3xl font-bold">📋 Alle Listings</h1>
<Link href="/admin/import">
<Button> Neues Airbnb importieren</Button>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.slug}`}>
<Card className="hover:shadow-xl transition-all hover:-translate-y-1 overflow-hidden">
{listing.coverImage && (
<div className="h-48 bg-slate-200 relative">
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-full object-cover"
/>
{listing.isFavorite && (
<div className="absolute top-2 right-2">
<span className="text-2xl"></span>
</div>
)}
<Card key={listing.id} className="overflow-hidden hover:shadow-lg transition-shadow">
{/* Image */}
<Link href={`/listings/${listing.slug}`}>
{listing.coverImage ? (
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-48 object-cover hover:opacity-90 transition"
/>
) : (
<div className="w-full h-48 bg-slate-200 flex items-center justify-center">
🏠 Kein Bild
</div>
)}
<CardContent className="p-4">
<h3 className="font-semibold text-lg mb-1 truncate">
</Link>
<CardContent className="p-4">
{/* Title - Clickable */}
<Link href={`/listings/${listing.slug}`}>
<h2 className="font-semibold text-lg mb-1 hover:text-blue-600 transition line-clamp-1">
{listing.title}
</h3>
<p className="text-sm text-slate-500 mb-2">
📍 {listing.locationText || "Kein Ort"}
</p>
</h2>
</Link>
<p className="text-sm text-slate-500 mb-2">📍 {listing.locationText || "Kein Ort"}</p>
<div className="flex justify-between items-center mb-3">
<span className="text-xl font-bold">
{listing.nightlyPrice?.toFixed(2) || "—"}
</span>
<span className="flex items-center gap-1">
{listing.rating?.toFixed(2) || "—"}
</span>
</div>
{/* Price & Rating */}
<div className="flex justify-between items-center mb-3">
<span className="text-xl font-bold">{listing.nightlyPrice?.toFixed(2) || "—"}</span>
<span className="text-sm"> {listing.rating?.toFixed(2) || "—"}</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{listing.bedrooms && (
<Badge variant="secondary">🛏 {listing.bedrooms}</Badge>
)}
{listing.beds && (
<Badge variant="secondary">🛏 {listing.beds}</Badge>
)}
{listing.bathrooms && (
<Badge variant="secondary">🚿 {listing.bathrooms}</Badge>
)}
{listing.maxSleepingPlaces && (
<Badge variant="secondary">
👥 {listing.maxSleepingPlaces}
{/* Tags */}
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{listing.tags.slice(0, 3).map((lt) => (
<Badge
key={lt.tag.id}
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
className="text-white text-xs"
>
{lt.tag.name}
</Badge>
)}
))}
</div>
)}
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{listing.tags.slice(0, 3).map((lt) => (
<Badge
key={lt.tag.id}
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
className="text-white text-xs"
>
{lt.tag.name}
</Badge>
))}
</div>
)}
{/* Sleep Info */}
{listing.suitableFor4 ? (
<p className="text-xs text-green-600 font-medium mb-3"> Geeignet für 4 Personen</p>
) : (
<p className="text-xs text-amber-600 font-medium mb-3">
Nicht ideal für 4 {listing.extraMattressesNeededFor4 ? `(+${listing.extraMattressesNeededFor4} Matratzen)` : ""}
</p>
)}
{listing.suitableFor4 && (
<div className="mt-2 text-xs text-green-600 font-medium">
Geeignet für 4 Personen
</div>
)}
</CardContent>
</Card>
</Link>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t">
<Link href={`/listings/${listing.slug}`} className="flex-1">
<Button variant="outline" className="w-full text-sm">
👁 Ansehen
</Button>
</Link>
<Link href={`/admin/listings/${listing.slug}`} className="flex-1">
<Button variant="outline" className="w-full text-sm">
Bearbeiten
</Button>
</Link>
<DeleteListingButton listingId={listing.id} listingTitle={listing.title} />
</div>
</CardContent>
</Card>
))}
</div>
{listings.length === 0 && (
<div className="text-center py-16 text-slate-500">
<p className="text-6xl mb-4">🏠</p>
<p className="text-xl mb-4">Keine Listings vorhanden</p>
<Link href="/admin/import" className="text-blue-600 hover:underline">
Erstes Listing importieren

View File

@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { formatPrice, formatRating } from "@/lib/utils";
interface Listing {
id: string;
slug: string;
title: string;
locationText: string | null;
latitude: number | null;
longitude: number | null;
nightlyPrice: number | null;
rating: number | null;
coverImage: string | null;
isFavorite: boolean;
}
export default function MapClient() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [MapComponents, setMapComponents] = useState<{
MapContainer: any;
TileLayer: any;
Marker: any;
Popup: any;
} | null>(null);
useEffect(() => {
// Load Leaflet CSS
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
link.crossOrigin = "";
document.head.appendChild(link);
// Load React-Leaflet components
import("react-leaflet").then((mod) => {
setMapComponents({
MapContainer: mod.MapContainer,
TileLayer: mod.TileLayer,
Marker: mod.Marker,
Popup: mod.Popup,
});
});
// Fetch listings
fetch("/api/listings")
.then((res) => res.json())
.then((data) => {
setListings(data.listings || []);
setLoading(false);
})
.catch((err) => {
console.error("Map fetch error:", err);
setError("Fehler beim Laden der Karte");
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-6">🗺 Kartenansicht</h1>
<Card>
<CardContent className="p-8 text-center">
<div className="animate-pulse">Lade Karte...</div>
</CardContent>
</Card>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-6">🗺 Kartenansicht</h1>
<Card>
<CardContent className="p-8 text-center text-red-500">
{error}
</CardContent>
</Card>
</div>
</div>
);
}
const listingsWithCoords = listings.filter((l) => l.latitude && l.longitude);
if (!MapComponents) {
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-6">🗺 Kartenansicht</h1>
<Card>
<CardContent className="p-8 text-center">
<div className="animate-pulse">Initialisiere Karte...</div>
</CardContent>
</Card>
</div>
</div>
);
}
const { MapContainer, TileLayer, Marker, Popup } = MapComponents;
const center: [number, number] =
listingsWithCoords.length > 0
? [listingsWithCoords[0].latitude!, listingsWithCoords[0].longitude!]
: [51.0504, 13.7373]; // Dresden as default
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-6">🗺 Kartenansicht</h1>
<Card>
<CardContent className="p-0">
<div style={{ height: "70vh", width: "100%" }} className="rounded-lg overflow-hidden">
<MapContainer
center={center}
zoom={11}
style={{ height: "100%", width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{listingsWithCoords.map((listing) => (
<Marker
key={listing.id}
position={[listing.latitude!, listing.longitude!]}
>
<Popup>
<div className="w-56">
{listing.coverImage && (
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-24 object-cover rounded mb-2"
/>
)}
<h3 className="font-semibold text-sm mb-1 line-clamp-1">
{listing.title}
</h3>
<p className="text-xs text-slate-500 mb-2">
{listing.locationText || "Kein Ort"}
</p>
<div className="flex justify-between text-sm mb-2">
<span className="font-bold">{formatPrice(listing.nightlyPrice)}</span>
<span> {formatRating(listing.rating)}</span>
</div>
<a
href={`/listings/${listing.slug}`}
className="text-blue-600 text-xs hover:underline"
>
Details
</a>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
</CardContent>
</Card>
{listingsWithCoords.length === 0 && (
<div className="text-center py-8 text-slate-500">
Keine Listings mit Koordinaten vorhanden
</div>
)}
<div className="mt-4 text-sm text-slate-500">
{listingsWithCoords.length} von {listings.length} Listings auf der Karte sichtbar
</div>
</div>
</div>
);
}

View File

@ -1,144 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import dynamic from "next/dynamic";
// Dynamically import Leaflet (SSR=false)
const MapContainer = dynamic(
() => import("react-leaflet").then((mod) => mod.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import("react-leaflet").then((mod) => mod.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import("react-leaflet").then((mod) => mod.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((mod) => mod.Popup),
{ ssr: false }
);
interface Listing {
id: string;
slug: string;
title: string;
locationText: string | null;
latitude: number | null;
longitude: number | null;
nightlyPrice: number | null;
rating: number | null;
coverImage: string | null;
isFavorite: boolean;
}
const MapClient = dynamic(() => import("./map-client"), {
ssr: false,
loading: () => (
<div className="h-[70vh] bg-slate-200 rounded animate-pulse"></div>
),
});
export default function MapPage() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/listings")
.then((res) => res.json())
.then((data) => {
setListings(data.listings || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="text-xl">Lade Karte...</div>
</div>
);
}
const listingsWithCoords = listings.filter(
(l) => l.latitude && l.longitude
);
return (
<div className="min-h-screen bg-slate-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">🗺 Kartenansicht</h1>
<Card>
<CardContent className="p-0">
<div className="h-[70vh] rounded-lg overflow-hidden">
{typeof window !== "undefined" && (
<MapContainer
center={
listingsWithCoords.length > 0
? [
listingsWithCoords[0].latitude!,
listingsWithCoords[0].longitude!,
]
: [51.0504, 13.7373]
}
zoom={11}
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{listingsWithCoords.map((listing) => (
<Marker
key={listing.id}
position={[listing.latitude!, listing.longitude!]}
>
<Popup>
<div className="w-56">
{listing.coverImage && (
<img
src={listing.coverImage}
alt={listing.title}
className="w-full h-24 object-cover rounded mb-2"
/>
)}
<h3 className="font-semibold text-sm mb-1">
{listing.title}
</h3>
<p className="text-xs text-slate-500 mb-2">
{listing.locationText}
</p>
<div className="flex justify-between text-sm mb-2">
<span className="font-bold">
{listing.nightlyPrice?.toFixed(2)}
</span>
<span> {listing.rating?.toFixed(2)}</span>
</div>
<a
href={`/listings/${listing.slug}`}
className="text-blue-600 text-xs hover:underline"
>
Details
</a>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
</div>
</CardContent>
</Card>
{listingsWithCoords.length === 0 && (
<div className="text-center py-8 text-slate-500">
Keine Listings mit Koordinaten vorhanden
</div>
)}
<div className="mt-4 text-sm text-slate-500">
{listingsWithCoords.length} von {listings.length} Listings auf der
Karte sichtbar
</div>
</div>
</div>
);
return <MapClient />;
}

View File

@ -15,15 +15,23 @@ export async function GET(request: NextRequest) {
rating: true,
coverImage: true,
isFavorite: true,
suitableFor4: true,
extraMattressesNeededFor4: true,
maxSleepingPlaces: true,
bedrooms: true,
beds: true,
bathrooms: true,
guestCount: true,
bedTypesSummary: true,
},
orderBy: { importedAt: "desc" },
});
return NextResponse.json({ listings });
} catch (error) {
console.error("Error fetching listings:", error);
console.error("API /listings error:", error);
return NextResponse.json(
{ error: "Failed to fetch listings" },
{ error: "Failed to fetch listings", listings: [] },
{ status: 500 }
);
}

View File

@ -13,8 +13,14 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "Airbnb Finder - Listings vergleichen & verwalten",
template: "%s | Airbnb Finder",
},
description: "Airbnb Listings importieren, vergleichen und für 4 Personen analysieren",
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({
@ -23,7 +29,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="de">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>