feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
@@ -10,18 +8,13 @@ import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
type Property,
|
||||
type PropertyMatch,
|
||||
type Activity,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PropertyMapView } from "@/components/map/property-map-view";
|
||||
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { PropertyDetailClient } from "@/components/properties/property-detail-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -71,216 +64,13 @@ export default async function PropertyDetailPage({ params }: Props) {
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
||||
const imageIds = parseImageIds(property.imageIds);
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
aktif: "bg-green-100 text-green-700",
|
||||
pasif: "bg-gray-100 text-gray-600",
|
||||
satildi: "bg-orange-100 text-orange-700",
|
||||
kiralandit: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/properties"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
İlanlar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{property.title}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{[property.neighborhood, property.district, property.city].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${statusColor[property.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{PROPERTY_STATUS_LABELS[property.status] ?? property.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Photo gallery */}
|
||||
{imageIds.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
imageIds.length === 1
|
||||
? "grid-cols-1"
|
||||
: imageIds.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{imageIds.map((fileId, i) => (
|
||||
<div
|
||||
key={fileId}
|
||||
className={`overflow-hidden rounded-lg border bg-muted ${i === 0 && imageIds.length > 2 ? "col-span-2 row-span-2" : ""}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(fileId, 1200, 900)}
|
||||
alt={`${property.title} fotoğraf ${i + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ maxHeight: i === 0 && imageIds.length > 2 ? "480px" : "240px" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Price */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
{property.price.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{property.currency ?? "TRY"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<Detail label="Emlak tipi" value={PROPERTY_TYPE_LABELS[property.propertyType]} />
|
||||
<Detail label="İlan türü" value={LISTING_TYPE_LABELS[property.listingType]} />
|
||||
{property.roomCount && <Detail label="Oda sayısı" value={property.roomCount} />}
|
||||
{property.netM2 && <Detail label="Net m²" value={`${property.netM2} m²`} />}
|
||||
{property.grossM2 && <Detail label="Brüt m²" value={`${property.grossM2} m²`} />}
|
||||
{property.floor != null && <Detail label="Kat" value={String(property.floor)} />}
|
||||
{property.totalFloors != null && (
|
||||
<Detail label="Top. kat" value={String(property.totalFloors)} />
|
||||
)}
|
||||
{property.buildingAge != null && (
|
||||
<Detail label="Bina yaşı" value={`${property.buildingAge} yıl`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.address && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Adres: </span>
|
||||
{property.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.mapLat != null && property.mapLng != null && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${property.mapLat},${property.mapLng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Google Maps'te aç ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.description && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">Açıklama</h2>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{property.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.mapLat != null && property.mapLng != null && (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold">Konum</h2>
|
||||
<PropertyMapView
|
||||
lat={property.mapLat}
|
||||
lng={property.mapLng}
|
||||
title={property.title}
|
||||
className="h-64 rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
{activities.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">Aktiviteler</h2>
|
||||
<div className="space-y-2">
|
||||
{activities.map((a) => (
|
||||
<div key={a.$id} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">
|
||||
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{a.title}</p>
|
||||
{a.description && (
|
||||
<p className="text-muted-foreground text-xs truncate">{a.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs ml-auto shrink-0">
|
||||
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matches sidebar */}
|
||||
<div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">
|
||||
İlgili Müşteriler
|
||||
{matches.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">({matches.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">Eşleşme yok.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{matches.map((m) => (
|
||||
<div key={m.$id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate">
|
||||
{customerMap[m.customerId] ?? m.customerId}
|
||||
</span>
|
||||
<ScoreBadge score={m.score} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{label}: </span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score?: number | null }) {
|
||||
const s = score ?? 0;
|
||||
const color =
|
||||
s >= 80
|
||||
? "bg-green-100 text-green-700"
|
||||
: s >= 60
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: s >= 40
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<PropertyDetailClient
|
||||
property={property}
|
||||
matches={matches}
|
||||
activities={activities}
|
||||
imageIds={imageIds}
|
||||
customerMap={customerMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user