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({
|
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 },
|
||||||
|
|||||||
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 { 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,
|
||||||
where: {
|
}: {
|
||||||
OR: [{ status: "SHORTLIST" }, { isFavorite: true }],
|
searchParams: Promise<{ ids?: string }>;
|
||||||
},
|
}) {
|
||||||
orderBy: { rating: "desc" },
|
const params = await searchParams;
|
||||||
include: {
|
const compareIds = params.ids?.split(",").filter(Boolean) || [];
|
||||||
sleepingOptions: true,
|
|
||||||
},
|
// 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) {
|
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>
|
||||||
<Link href="/listings" className="text-blue-600 hover:underline">
|
<div className="flex gap-4 items-center">
|
||||||
← Zu allen Listings
|
<Link href="/listings" className="text-blue-600 hover:underline">
|
||||||
</Link>
|
← Zu allen Listings
|
||||||
|
</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 ? (
|
|
||||||
<span className="text-green-600 font-bold">0 ✅</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-amber-600 font-bold">
|
|
||||||
{listing.extraMattressesNeededFor4}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
"—"
|
"—"
|
||||||
|
) : listing.extraMattressesNeededFor4 === 0 ? (
|
||||||
|
<span className="text-green-600 font-bold">0 ✅</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-600 font-bold">
|
||||||
|
{listing.extraMattressesNeededFor4}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { importedAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.listing.findMany({
|
||||||
|
where: { isFavorite: true },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const recentListings = await prisma.listing.findMany({
|
const avgPrice = stats._avg.nightlyPrice?.toFixed(2) || "—";
|
||||||
take: 5,
|
const avgRating = stats._avg.rating?.toFixed(2) || "—";
|
||||||
orderBy: { importedAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const favoriteListings = await prisma.listing.findMany({
|
|
||||||
take: 3,
|
|
||||||
where: { isFavorite: true },
|
|
||||||
orderBy: { rating: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
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 ? (
|
||||||
{recentListings.map((listing) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
<Card key={listing.id} className="hover:shadow-lg transition-shadow">
|
{recentListings.map((listing) => (
|
||||||
<CardContent className="p-4">
|
<Link key={listing.id} href={`/listings/${listing.slug}`}>
|
||||||
<h3 className="font-medium truncate">{listing.title}</h3>
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||||
<p className="text-sm text-slate-500">{listing.locationText}</p>
|
{listing.coverImage ? (
|
||||||
<div className="flex justify-between mt-2">
|
<img
|
||||||
<span>€{listing.nightlyPrice?.toFixed(2) || "—"}</span>
|
src={listing.coverImage}
|
||||||
<span>⭐ {listing.rating?.toFixed(2) || "—"}</span>
|
alt={listing.title}
|
||||||
</div>
|
className="w-full h-32 object-cover"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
) : (
|
||||||
))}
|
<div className="w-full h-32 bg-slate-200 flex items-center justify-center">
|
||||||
</div>
|
🏠
|
||||||
|
</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>
|
</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}
|
||||||
<span>⭐ {listing.rating?.toFixed(2) || "—"}</span>
|
className="w-full h-32 object-cover"
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<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>
|
||||||
</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 { 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 };
|
|
||||||
}) {
|
export default async function ListingDetailPage({ params }: PageProps) {
|
||||||
const session = await auth();
|
const { slug } = await params;
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,51 +158,54 @@ 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
{listing.tags.length > 0 && (
|
||||||
{listing.tags.map((lt) => (
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<Badge
|
{listing.tags.map((lt) => (
|
||||||
key={lt.tag.id}
|
<Badge
|
||||||
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
|
key={lt.tag.id}
|
||||||
className="text-white"
|
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
|
||||||
>
|
className="text-white"
|
||||||
{lt.tag.name}
|
>
|
||||||
</Badge>
|
{lt.tag.name}
|
||||||
))}
|
</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}
|
||||||
|
|||||||
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 { 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,91 +21,92 @@ 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>
|
||||||
)}
|
)}
|
||||||
<CardContent className="p-4">
|
</Link>
|
||||||
<h3 className="font-semibold text-lg mb-1 truncate">
|
|
||||||
|
<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}
|
{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>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
{/* Price & Rating */}
|
||||||
<span className="text-xl font-bold">
|
<div className="flex justify-between items-center mb-3">
|
||||||
€{listing.nightlyPrice?.toFixed(2) || "—"}
|
<span className="text-xl font-bold">€{listing.nightlyPrice?.toFixed(2) || "—"}</span>
|
||||||
</span>
|
<span className="text-sm">⭐ {listing.rating?.toFixed(2) || "—"}</span>
|
||||||
<span className="flex items-center gap-1">
|
</div>
|
||||||
⭐ {listing.rating?.toFixed(2) || "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
{/* Tags */}
|
||||||
{listing.bedrooms && (
|
{listing.tags.length > 0 && (
|
||||||
<Badge variant="secondary">🛏️ {listing.bedrooms}</Badge>
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
)}
|
{listing.tags.slice(0, 3).map((lt) => (
|
||||||
{listing.beds && (
|
<Badge
|
||||||
<Badge variant="secondary">🛏 {listing.beds}</Badge>
|
key={lt.tag.id}
|
||||||
)}
|
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
|
||||||
{listing.bathrooms && (
|
className="text-white text-xs"
|
||||||
<Badge variant="secondary">🚿 {listing.bathrooms}</Badge>
|
>
|
||||||
)}
|
{lt.tag.name}
|
||||||
{listing.maxSleepingPlaces && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
👥 {listing.maxSleepingPlaces}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{listing.tags.length > 0 && (
|
{/* Sleep Info */}
|
||||||
<div className="flex flex-wrap gap-1">
|
{listing.suitableFor4 ? (
|
||||||
{listing.tags.slice(0, 3).map((lt) => (
|
<p className="text-xs text-green-600 font-medium mb-3">✅ Geeignet für 4 Personen</p>
|
||||||
<Badge
|
) : (
|
||||||
key={lt.tag.id}
|
<p className="text-xs text-amber-600 font-medium mb-3">
|
||||||
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
|
⚠️ Nicht ideal für 4 {listing.extraMattressesNeededFor4 ? `(+${listing.extraMattressesNeededFor4} Matratzen)` : ""}
|
||||||
className="text-white text-xs"
|
</p>
|
||||||
>
|
)}
|
||||||
{lt.tag.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{listing.suitableFor4 && (
|
{/* Actions */}
|
||||||
<div className="mt-2 text-xs text-green-600 font-medium">
|
<div className="flex gap-2 pt-2 border-t">
|
||||||
✅ Geeignet für 4 Personen
|
<Link href={`/listings/${listing.slug}`} className="flex-1">
|
||||||
</div>
|
<Button variant="outline" className="w-full text-sm">
|
||||||
)}
|
👁️ Ansehen
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</Link>
|
||||||
</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>
|
</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
|
||||||
|
|||||||
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";
|
"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='© <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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user