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:
parent
bd68daec83
commit
4fd675431b
@ -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 },
|
||||
|
||||
41
src/app/(protected)/compare/compare-button.tsx
Normal file
41
src/app/(protected)/compare/compare-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
33
src/app/(protected)/listings/[slug]/error.tsx
Normal file
33
src/app/(protected)/listings/[slug]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(protected)/listings/[slug]/loading.tsx
Normal file
22
src/app/(protected)/listings/[slug]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/app/(protected)/listings/[slug]/not-found.tsx
Normal file
18
src/app/(protected)/listings/[slug]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
38
src/app/(protected)/listings/actions.ts
Normal file
38
src/app/(protected)/listings/actions.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
42
src/app/(protected)/listings/delete-button.tsx
Normal file
42
src/app/(protected)/listings/delete-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
186
src/app/(protected)/map/map-client.tsx
Normal file
186
src/app/(protected)/map/map-client.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
@ -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='© <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 />;
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user