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({ export default async function EditListingPage({
params, params,
}: { }: {
params: { slug: string }; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params;
const listing = await prisma.listing.findUnique({ const listing = await prisma.listing.findUnique({
where: { slug: params.slug }, where: { slug },
include: { include: {
tags: { tags: {
include: { tag: true }, 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 { prisma } from "@/lib/prisma";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { formatPrice, formatRating } from "@/lib/utils";
import { CompareButton } from "./compare-button";
export default async function ComparePage() { export default async function ComparePage({
const listings = await prisma.listing.findMany({ 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: { where: {
OR: [{ status: "SHORTLIST" }, { isFavorite: true }], OR: [{ status: "SHORTLIST" }, { isFavorite: true }],
}, },
orderBy: { rating: "desc" }, orderBy: { rating: "desc" },
include: { include: { sleepingOptions: true, tags: { include: { tag: true } } },
sleepingOptions: 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) { if (listings.length === 0) {
@ -20,60 +41,100 @@ export default async function ComparePage() {
<div className="container mx-auto text-center py-16"> <div className="container mx-auto text-center py-16">
<h1 className="text-3xl font-bold mb-4">📊 Vergleich</h1> <h1 className="text-3xl font-bold mb-4">📊 Vergleich</h1>
<p className="text-slate-500 mb-4"> <p className="text-slate-500 mb-4">
Markiere Listings als Favorit oder setze Status auf "Shortlist" für Keine Listings zum Vergleichen vorhanden.
den Vergleich
</p> </p>
<Link href="/listings" className="text-blue-600 hover:underline"> <p className="text-slate-400 mb-6 text-sm">
Zu allen Listings 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> </Link>
</div> </div>
</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 ( return (
<div className="min-h-screen bg-slate-50 p-8"> <div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto"> <div className="container mx-auto">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">📊 Vergleich ({listings.length})</h1> <h1 className="text-3xl font-bold">📊 Vergleich ({listings.length})</h1>
<div className="flex gap-4 items-center">
<Link href="/listings" className="text-blue-600 hover:underline"> <Link href="/listings" className="text-blue-600 hover:underline">
Zu allen Listings Zu allen Listings
</Link> </Link>
</div> </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"> <div className="overflow-x-auto">
<table className="w-full border-collapse bg-white rounded-lg overflow-hidden shadow-lg"> <table className="w-full border-collapse bg-white rounded-lg overflow-hidden shadow-lg">
<thead className="bg-slate-800 text-white"> <thead className="bg-slate-800 text-white">
<tr> <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">/Nacht</th>
<th className="p-4 text-center"> Rating</th> <th className="p-4 text-center"> Rating</th>
<th className="p-4 text-center">📍 Ort</th> <th className="p-4 text-center">📍 Ort</th>
<th className="p-4 text-center">🛏 Schlafz.</th> <th className="p-4 text-center">🛏 Schlafz.</th>
<th className="p-4 text-center">🛏 Betten</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">👥 Max</th>
<th className="p-4 text-center">4er-tauglich</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">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> </tr>
</thead> </thead>
<tbody> <tbody>
{listings.map((listing, idx) => { {listings.map((listing, idx) => {
const isBestPrice = const isBestPrice = listing.nightlyPrice === minPrice;
listing.nightlyPrice === const isBestRating = listing.rating === maxRating;
Math.min(...listings.map((l) => l.nightlyPrice || Infinity));
const isBestRating =
listing.rating ===
Math.max(...listings.map((l) => l.rating || 0));
return ( return (
<tr <tr
key={listing.id} key={listing.id}
className={ className={idx % 2 === 0 ? "bg-white" : "bg-slate-50"}
idx % 2 === 0 ? "bg-white" : "bg-slate-50"
}
> >
<td className="p-4"> <td className="p-4 sticky left-0 bg-inherit z-10">
<Link <Link
href={`/listings/${listing.slug}`} href={`/listings/${listing.slug}`}
className="hover:text-blue-600" className="hover:text-blue-600"
@ -100,7 +161,7 @@ export default async function ComparePage() {
isBestPrice ? "text-green-600 bg-green-50" : "" isBestPrice ? "text-green-600 bg-green-50" : ""
}`} }`}
> >
{listing.nightlyPrice?.toFixed(2) || "—"} {formatPrice(listing.nightlyPrice)}
{isBestPrice && ( {isBestPrice && (
<Badge className="ml-2 bg-green-600">Beste</Badge> <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" : "" isBestRating ? "text-green-600 bg-green-50 font-bold" : ""
}`} }`}
> >
{listing.rating?.toFixed(2) || "—"} {formatRating(listing.rating)}
{isBestRating && ( {isBestRating && (
<Badge className="ml-2 bg-green-600">Top</Badge> <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"> <td className="p-4 text-center text-sm">
{listing.locationText || "—"} {listing.locationText || "—"}
</td> </td>
<td className="p-4 text-center">{listing.bedrooms || "—"}</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.beds ?? "—"}</td>
<td className="p-4 text-center">
{listing.bathrooms != null ? listing.bathrooms.toFixed(1) : "—"}
</td>
<td className="p-4 text-center font-medium"> <td className="p-4 text-center font-medium">
{listing.maxSleepingPlaces || "—"} {listing.maxSleepingPlaces ?? listing.guestCount ?? "—"}
</td> </td>
<td className="p-4 text-center"> <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-green-600 font-bold"> Ja</span>
) : ( ) : (
<span className="text-red-500"> Nein</span> <span className="text-red-500"> Nein</span>
)} )}
</td> </td>
<td className="p-4 text-center"> <td className="p-4 text-center">
{listing.extraMattressesNeededFor4 !== null ? ( {listing.extraMattressesNeededFor4 == null ? (
listing.extraMattressesNeededFor4 === 0 ? ( "—"
) : listing.extraMattressesNeededFor4 === 0 ? (
<span className="text-green-600 font-bold">0 </span> <span className="text-green-600 font-bold">0 </span>
) : ( ) : (
<span className="text-amber-600 font-bold"> <span className="text-amber-600 font-bold">
{listing.extraMattressesNeededFor4} {listing.extraMattressesNeededFor4}
</span> </span>
)
) : (
"—"
)} )}
</td> </td>
<td className="p-4 text-center text-xs"> <td className="p-4 text-center">
{listing.bedTypesSummary || "—"} <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> </td>
</tr> </tr>
); );

View File

@ -1,18 +1,10 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { auth } from "@/lib/auth";
import Link from "next/link"; import Link from "next/link";
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await auth(); const [totalCount, stats, favoriteCount, recentListings, favoriteListings] = await Promise.all([
if (!session) {
redirect("/login");
}
const [totalCount, stats, favoriteCount] = await Promise.all([
prisma.listing.count(), prisma.listing.count(),
prisma.listing.aggregate({ prisma.listing.aggregate({
_avg: { _avg: {
@ -21,29 +13,31 @@ export default async function DashboardPage() {
}, },
}), }),
prisma.listing.count({ where: { isFavorite: true } }), prisma.listing.count({ where: { isFavorite: true } }),
]); prisma.listing.findMany({
const recentListings = await prisma.listing.findMany({
take: 5, take: 5,
orderBy: { importedAt: "desc" }, orderBy: { importedAt: "desc" },
}); }),
prisma.listing.findMany({
const favoriteListings = await prisma.listing.findMany({
take: 3,
where: { isFavorite: true }, where: { isFavorite: true },
orderBy: { rating: "desc" }, take: 5,
}); }),
]);
const avgPrice = stats._avg.nightlyPrice?.toFixed(2) || "—";
const avgRating = stats._avg.rating?.toFixed(2) || "—";
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8"> <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"> <Link href="/admin/import">
<Button> Neues Airbnb importieren</Button> <Button> Neues Airbnb importieren</Button>
</Link> </Link>
</div> </div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card> <Card>
<CardHeader> <CardHeader>
@ -60,9 +54,7 @@ export default async function DashboardPage() {
<CardTitle className="text-sm text-slate-600"> Durchschnittspreis</CardTitle> <CardTitle className="text-sm text-slate-600"> Durchschnittspreis</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-bold"> <p className="text-3xl font-bold">{avgPrice}</p>
{stats._avg.nightlyPrice?.toFixed(2) || "—"}
</p>
<p className="text-sm text-slate-500">pro Nacht</p> <p className="text-sm text-slate-500">pro Nacht</p>
</CardContent> </CardContent>
</Card> </Card>
@ -72,9 +64,7 @@ export default async function DashboardPage() {
<CardTitle className="text-sm text-slate-600"> Durchschnittsbewertung</CardTitle> <CardTitle className="text-sm text-slate-600"> Durchschnittsbewertung</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-bold"> <p className="text-3xl font-bold">{avgRating}</p>
{stats._avg.rating?.toFixed(2) || "—"}
</p>
<p className="text-sm text-slate-500">von 5.00</p> <p className="text-sm text-slate-500">von 5.00</p>
</CardContent> </CardContent>
</Card> </Card>
@ -90,39 +80,76 @@ export default async function DashboardPage() {
</Card> </Card>
</div> </div>
{/* Recent Listings */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Zuletzt importiert</h2> <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.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{recentListings.map((listing) => ( {recentListings.map((listing) => (
<Card key={listing.id} className="hover:shadow-lg transition-shadow"> <Link key={listing.id} href={`/listings/${listing.slug}`}>
<CardContent className="p-4"> <Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<h3 className="font-medium truncate">{listing.title}</h3> {listing.coverImage ? (
<p className="text-sm text-slate-500">{listing.locationText}</p> <img
<div className="flex justify-between mt-2"> src={listing.coverImage}
<span>{listing.nightlyPrice?.toFixed(2) || "—"}</span> 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> <span> {listing.rating?.toFixed(2) || "—"}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Link>
))} ))}
</div> </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> </div>
{/* Favorites */}
{favoriteListings.length > 0 && ( {favoriteListings.length > 0 && (
<div> <div>
<h2 className="text-xl font-semibold mb-4"> Favoriten</h2> <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) => ( {favoriteListings.map((listing) => (
<Card key={listing.id} className="hover:shadow-lg transition-shadow"> <Link key={listing.id} href={`/listings/${listing.slug}`}>
<CardContent className="p-4"> <Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<h3 className="font-medium truncate">{listing.title}</h3> {listing.coverImage ? (
<p className="text-sm text-slate-500">{listing.locationText}</p> <img
<div className="flex justify-between mt-2"> src={listing.coverImage}
<span>{listing.nightlyPrice?.toFixed(2) || "—"}</span> 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> <span> {listing.rating?.toFixed(2) || "—"}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Link>
))} ))}
</div> </div>
</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 { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import Link from "next/link"; import Link from "next/link";
import { formatPrice, formatRating } from "@/lib/utils";
export default async function ListingDetailPage({ interface PageProps {
params, params: Promise<{ slug: string }>;
}: {
params: { slug: string };
}) {
const session = await auth();
if (!session) {
redirect("/login");
} }
export default async function ListingDetailPage({ params }: PageProps) {
const { slug } = await params;
const listing = await prisma.listing.findUnique({ const listing = await prisma.listing.findUnique({
where: { slug: params.slug }, where: { slug },
include: { include: {
images: true, images: true,
notes: true, notes: true,
sleepingOptions: true, sleepingOptions: true,
tags: { tags: { include: { tag: true } },
include: {
tag: true,
},
},
}, },
}); });
if (!listing) { if (!listing) {
redirect("/listings"); notFound();
} }
const isBest4Person = listing.suitableFor4 === true;
const extraMattresses = listing.extraMattressesNeededFor4 ?? null;
return ( return (
<div className="min-h-screen bg-slate-50 p-8"> <div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto max-w-6xl"> <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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Images */} {/* Cover Image */}
<Card> <Card>
<CardContent className="p-0 overflow-hidden"> <CardContent className="p-0 overflow-hidden">
{listing.coverImage ? ( {listing.coverImage ? (
@ -57,8 +52,8 @@ export default async function ListingDetailPage({
className="w-full h-96 object-cover" className="w-full h-96 object-cover"
/> />
) : ( ) : (
<div className="w-full h-96 bg-slate-200 flex items-center justify-center"> <div className="w-full h-96 bg-slate-200 flex items-center justify-center text-slate-500">
Kein Bild Kein Bild vorhanden
</div> </div>
)} )}
</CardContent> </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 className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div> <div>
<p className="text-sm text-slate-500">Schlafzimmer</p> <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>
<div> <div>
<p className="text-sm text-slate-500">Betten</p> <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>
<div> <div>
<p className="text-sm text-slate-500">Badezimmer</p> <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>
<div> <div>
<p className="text-sm text-slate-500">Max Gäste</p> <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>
</div> </div>
{listing.description && ( {listing.description && (
<div> <div>
<h3 className="font-medium mb-2">Beschreibung</h3> <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> </div>
)} )}
</CardContent> </CardContent>
@ -105,14 +102,14 @@ export default async function ListingDetailPage({
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-slate-50 rounded"> <div className="p-4 bg-slate-50 rounded">
<p className="text-sm text-slate-500">4 Personen geeignet</p> <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"}`}> <p className={`text-2xl font-bold ${isBest4Person ? "text-green-600" : "text-red-500"}`}>
{listing.suitableFor4 ? "✅ Ja" : "❌ Nein"} {listing.suitableFor4 == null ? "❓ Unbekannt" : isBest4Person ? "✅ Ja" : "❌ Nein"}
</p> </p>
</div> </div>
<div className="p-4 bg-slate-50 rounded"> <div className="p-4 bg-slate-50 rounded">
<p className="text-sm text-slate-500">Extra Matratzen für 4</p> <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"}`}> <p className={`text-2xl font-bold ${extraMattresses === 0 ? "text-green-600" : extraMattresses != null && extraMattresses > 0 ? "text-amber-600" : ""}`}>
{listing.extraMattressesNeededFor4 ?? "—"} {extraMattresses ?? "—"}
</p> </p>
</div> </div>
</div> </div>
@ -161,31 +158,26 @@ export default async function ListingDetailPage({
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<h1 className="text-2xl font-bold mb-2">{listing.title}</h1> <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"> <div className="flex items-baseline gap-2 mb-4">
<span className="text-4xl font-bold"> <span className="text-4xl font-bold">{formatPrice(listing.nightlyPrice)}</span>
{listing.nightlyPrice?.toFixed(2) || "—"}
</span>
<span className="text-slate-500">/ Nacht</span> <span className="text-slate-500">/ Nacht</span>
</div> </div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<span className="text-xl"></span> <span className="text-xl"></span>
<span className="font-semibold">{listing.rating?.toFixed(2) || "—"}</span> <span className="font-semibold">{formatRating(listing.rating)}</span>
{listing.reviewCount && ( {listing.reviewCount != null && (
<span className="text-slate-500"> <span className="text-slate-500">({listing.reviewCount} Bewertungen)</span>
({listing.reviewCount} Bewertungen)
</span>
)} )}
</div> </div>
{listing.hostName && ( {listing.hostName && (
<p className="text-sm text-slate-500 mb-4"> <p className="text-sm text-slate-500 mb-4">👤 Host: {listing.hostName}</p>
👤 Host: {listing.hostName}
</p>
)} )}
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
{listing.tags.map((lt) => ( {listing.tags.map((lt) => (
<Badge <Badge
@ -197,15 +189,23 @@ export default async function ListingDetailPage({
</Badge> </Badge>
))} ))}
</div> </div>
)}
<a <a
href={listing.airbnbUrl} href={listing.airbnbUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 🔗 Auf Airbnb ansehen
</a> </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> </CardContent>
</Card> </Card>
@ -216,10 +216,12 @@ export default async function ListingDetailPage({
<Badge <Badge
className={ className={
listing.status === "SHORTLIST" listing.status === "SHORTLIST"
? "bg-green-500" ? "bg-green-500 text-white"
: listing.status === "REJECTED" : listing.status === "REJECTED"
? "bg-red-500" ? "bg-red-500 text-white"
: "bg-slate-500" : listing.status === "INTERESTING"
? "bg-blue-500 text-white"
: "bg-slate-500 text-white"
} }
> >
{listing.status} {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 { prisma } from "@/lib/prisma";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { DeleteListingButton } from "./delete-button";
export default async function ListingsPage() { export default async function ListingsPage() {
const listings = await prisma.listing.findMany({ const listings = await prisma.listing.findMany({
@ -19,66 +21,49 @@ export default async function ListingsPage() {
<div className="min-h-screen bg-slate-50 p-8"> <div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto"> <div className="container mx-auto">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">🏠 Alle Listings</h1> <h1 className="text-3xl font-bold">📋 Alle Listings</h1>
<Link href="/admin/import" className="text-blue-600 hover:underline"> <Link href="/admin/import">
Neu importieren <Button> Neues Airbnb importieren</Button>
</Link> </Link>
</div> </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) => ( {listings.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.slug}`}> <Card key={listing.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<Card className="hover:shadow-xl transition-all hover:-translate-y-1 overflow-hidden"> {/* Image */}
{listing.coverImage && ( <Link href={`/listings/${listing.slug}`}>
<div className="h-48 bg-slate-200 relative"> {listing.coverImage ? (
<img <img
src={listing.coverImage} src={listing.coverImage}
alt={listing.title} alt={listing.title}
className="w-full h-full object-cover" className="w-full h-48 object-cover hover:opacity-90 transition"
/> />
{listing.isFavorite && ( ) : (
<div className="absolute top-2 right-2"> <div className="w-full h-48 bg-slate-200 flex items-center justify-center">
<span className="text-2xl"></span> 🏠 Kein Bild
</div>
)}
</div> </div>
)} )}
</Link>
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="font-semibold text-lg mb-1 truncate"> {/* 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} {listing.title}
</h3> </h2>
<p className="text-sm text-slate-500 mb-2"> </Link>
📍 {listing.locationText || "Kein Ort"}
</p>
<p className="text-sm text-slate-500 mb-2">📍 {listing.locationText || "Kein Ort"}</p>
{/* Price & Rating */}
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="text-xl font-bold"> <span className="text-xl font-bold">{listing.nightlyPrice?.toFixed(2) || "—"}</span>
{listing.nightlyPrice?.toFixed(2) || "—"} <span className="text-sm"> {listing.rating?.toFixed(2) || "—"}</span>
</span>
<span className="flex items-center gap-1">
{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}
</Badge>
)}
</div> </div>
{/* Tags */}
{listing.tags.length > 0 && ( {listing.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1 mb-3">
{listing.tags.slice(0, 3).map((lt) => ( {listing.tags.slice(0, 3).map((lt) => (
<Badge <Badge
key={lt.tag.id} key={lt.tag.id}
@ -91,19 +76,37 @@ export default async function ListingsPage() {
</div> </div>
)} )}
{listing.suitableFor4 && ( {/* Sleep Info */}
<div className="mt-2 text-xs text-green-600 font-medium"> {listing.suitableFor4 ? (
Geeignet für 4 Personen <p className="text-xs text-green-600 font-medium mb-3"> Geeignet für 4 Personen</p>
</div> ) : (
<p className="text-xs text-amber-600 font-medium mb-3">
Nicht ideal für 4 {listing.extraMattressesNeededFor4 ? `(+${listing.extraMattressesNeededFor4} Matratzen)` : ""}
</p>
)} )}
{/* 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> </CardContent>
</Card> </Card>
</Link>
))} ))}
</div> </div>
{listings.length === 0 && ( {listings.length === 0 && (
<div className="text-center py-16 text-slate-500"> <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> <p className="text-xl mb-4">Keine Listings vorhanden</p>
<Link href="/admin/import" className="text-blue-600 hover:underline"> <Link href="/admin/import" className="text-blue-600 hover:underline">
Erstes Listing importieren 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"; "use client";
import { useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
// Dynamically import Leaflet (SSR=false) const MapClient = dynamic(() => import("./map-client"), {
const MapContainer = dynamic( ssr: false,
() => import("react-leaflet").then((mod) => mod.MapContainer), loading: () => (
{ ssr: false } <div className="h-[70vh] bg-slate-200 rounded animate-pulse"></div>
); ),
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;
}
export default function MapPage() { export default function MapPage() {
const [listings, setListings] = useState<Listing[]>([]); return <MapClient />;
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>
);
} }

View File

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

View File

@ -13,8 +13,14 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: {
description: "Generated by create next app", 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({ export default function RootLayout({
@ -23,7 +29,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="de">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >