From b1d3ee36815472c8db7cdc83a768ca362e7f56ac Mon Sep 17 00:00:00 2001 From: egecankomur Date: Tue, 5 May 2026 22:00:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20ilan=20listesine=20harita=20g=C3=B6r?= =?UTF-8?q?=C3=BCn=C3=BCm=C3=BC=20eklendi=20(split=20layout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Liste / Harita toggle butonu header'da - Harita modunda sol panel: kart listesi (fotoğraf, fiyat, oda/m²) + sağ panel: MapLibre harita tüm koordinatlı ilanlar - İlan renkleri duruma göre: aktif=mavi, pasif=gri, satıldı=turuncu, kiralandı=mor - Pini tıkla → kart listesinde o ilanın kartına scroll - Kartı tıkla → haritada o ilanın pinine flyTo + popup açılır - Popup içinde başlık, fiyat, özellikler, 'Detay →' linki - Koordinatsız ilanlar listede görünür ama haritada pin yok (📍 Konum yok) - PropertiesMapView: dynamic next/dynamic wrapper (ssr: false) --- src/app/(dashboard)/properties/page.tsx | 2 +- .../map/properties-map-view-inner.tsx | 137 ++++++++ src/components/map/properties-map-view.tsx | 31 ++ .../properties/properties-client.tsx | 310 +++++++++++++----- 4 files changed, 393 insertions(+), 87 deletions(-) create mode 100644 src/components/map/properties-map-view-inner.tsx create mode 100644 src/components/map/properties-map-view.tsx diff --git a/src/app/(dashboard)/properties/page.tsx b/src/app/(dashboard)/properties/page.tsx index a129b1b..e78b3d6 100644 --- a/src/app/(dashboard)/properties/page.tsx +++ b/src/app/(dashboard)/properties/page.tsx @@ -9,7 +9,7 @@ export default async function PropertiesPage() { const properties = await listProperties(ctx.tenantId); return ( -
+
); diff --git a/src/components/map/properties-map-view-inner.tsx b/src/components/map/properties-map-view-inner.tsx new file mode 100644 index 0000000..c799ecd --- /dev/null +++ b/src/components/map/properties-map-view-inner.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import type { Property } from "@/lib/appwrite/schema"; +import { + PROPERTY_TYPE_LABELS, + LISTING_TYPE_LABELS, +} from "@/lib/appwrite/schema"; + +const STYLE_URL = "https://tiles.openfreemap.org/styles/bright"; +const TURKEY_CENTER: [number, number] = [35.0, 39.0]; + +const STATUS_COLORS: Record = { + aktif: "#2563eb", + pasif: "#9ca3af", + satildi: "#f97316", + kiralandit: "#8b5cf6", +}; + +interface Props { + properties: Property[]; + selectedId?: string | null; + onSelect: (id: string) => void; +} + +export function PropertiesMapViewInner({ properties, selectedId, onSelect }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef>(new Map()); + const popupsRef = useRef>(new Map()); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + const mapped = properties.filter((p) => p.mapLat != null && p.mapLng != null); + + useEffect(() => { + if (!containerRef.current) return; + + let center: [number, number] = TURKEY_CENTER; + let zoom = 5.5; + if (mapped.length === 1) { + center = [mapped[0].mapLng!, mapped[0].mapLat!]; + zoom = 14; + } else if (mapped.length > 1) { + const avgLng = mapped.reduce((s, p) => s + p.mapLng!, 0) / mapped.length; + const avgLat = mapped.reduce((s, p) => s + p.mapLat!, 0) / mapped.length; + center = [avgLng, avgLat]; + zoom = 9; + } + + const map = new maplibregl.Map({ + container: containerRef.current, + style: STYLE_URL, + center, + zoom, + }); + + map.addControl(new maplibregl.NavigationControl(), "top-right"); + mapRef.current = map; + + map.on("load", () => { + for (const p of mapped) { + const color = STATUS_COLORS[p.status] ?? "#2563eb"; + + const popup = new maplibregl.Popup({ + offset: 28, + closeButton: true, + maxWidth: "260px", + className: "property-popup", + }).setHTML(` +
+

${p.title}

+

${[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}

+

${p.price.toLocaleString("tr-TR")} ${p.currency ?? "TRY"}

+
+ ${PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType} + ${LISTING_TYPE_LABELS[p.listingType] ?? p.listingType} + ${p.roomCount ? `${p.roomCount}` : ""} + ${p.netM2 ? `${p.netM2} m²` : ""} +
+ Detay → +
+ `); + + const marker = new maplibregl.Marker({ color }) + .setLngLat([p.mapLng!, p.mapLat!]) + .setPopup(popup) + .addTo(map); + + marker.getElement().addEventListener("click", () => { + onSelectRef.current(p.$id); + }); + + markersRef.current.set(p.$id, marker); + popupsRef.current.set(p.$id, popup); + } + }); + + return () => { + map.remove(); + mapRef.current = null; + markersRef.current.clear(); + popupsRef.current.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fly to + open popup when selection changes + useEffect(() => { + if (!selectedId || !mapRef.current) return; + const prop = mapped.find((p) => p.$id === selectedId); + if (!prop) return; + + mapRef.current.flyTo({ + center: [prop.mapLng!, prop.mapLat!], + zoom: Math.max(mapRef.current.getZoom(), 14), + duration: 700, + }); + + const marker = markersRef.current.get(selectedId); + if (marker && !marker.getPopup().isOpen()) { + marker.togglePopup(); + } + }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps + + if (mapped.length === 0) { + return ( +
+ Koordinatlı ilan yok — ilan formundan konum ekleyin. +
+ ); + } + + return
; +} diff --git a/src/components/map/properties-map-view.tsx b/src/components/map/properties-map-view.tsx new file mode 100644 index 0000000..bedbc5e --- /dev/null +++ b/src/components/map/properties-map-view.tsx @@ -0,0 +1,31 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { MapPin } from "lucide-react"; +import type { Property } from "@/lib/appwrite/schema"; + +const Inner = dynamic( + () => + import("./properties-map-view-inner").then((m) => m.PropertiesMapViewInner), + { + ssr: false, + loading: () => ( +
+
+ + Harita yükleniyor... +
+
+ ), + }, +); + +interface Props { + properties: Property[]; + selectedId?: string | null; + onSelect: (id: string) => void; +} + +export function PropertiesMapView(props: Props) { + return ; +} diff --git a/src/components/properties/properties-client.tsx b/src/components/properties/properties-client.tsx index 6d45de1..515b470 100644 --- a/src/components/properties/properties-client.tsx +++ b/src/components/properties/properties-client.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import Link from "next/link"; -import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink } from "lucide-react"; +import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, List, Map } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -15,6 +15,8 @@ import { } from "@/components/ui/dropdown-menu"; import { deletePropertyAction } from "@/lib/appwrite/property-actions"; import { PropertyFormSheet } from "./property-form-sheet"; +import { PropertiesMapView } from "@/components/map/properties-map-view"; +import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils"; import type { Property } from "@/lib/appwrite/schema"; import { PROPERTY_STATUS_LABELS, @@ -26,20 +28,19 @@ interface PropertiesClientProps { initialProperties: Property[]; } +type ViewMode = "list" | "map"; + export function PropertiesClient({ initialProperties }: PropertiesClientProps) { const [properties, setProperties] = useState(initialProperties); const [sheetOpen, setSheetOpen] = useState(false); const [editing, setEditing] = useState(null); + const [viewMode, setViewMode] = useState("list"); + const [selectedId, setSelectedId] = useState(null); + const rowRefs = useRef>({}); + const cardRefs = useRef>({}); - function openCreate() { - setEditing(null); - setSheetOpen(true); - } - - function openEdit(p: Property) { - setEditing(p); - setSheetOpen(true); - } + function openCreate() { setEditing(null); setSheetOpen(true); } + function openEdit(p: Property) { setEditing(p); setSheetOpen(true); } async function handleDelete(p: Property) { if (!confirm(`"${p.title}" silinsin mi?`)) return; @@ -52,94 +53,231 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) { } } - function handleSuccess() { - // Revalidation handles the data refresh; re-fetching in client is not - // necessary since the page will reload on next navigation. - // For immediate local update we just remove the item or close the sheet. + function handleMapSelect(id: string) { + setSelectedId(id); + // Scroll the card into view in the list panel + const card = cardRefs.current[id]; + card?.scrollIntoView({ behavior: "smooth", block: "nearest" }); } + function handleCardClick(id: string) { + setSelectedId(id === selectedId ? null : id); + } + + const mappedCount = properties.filter((p) => p.mapLat != null && p.mapLng != null).length; + return ( -
-
-

İlanlar

- +
+ {/* Header */} +
+
+

İlanlar

+ {viewMode === "map" && mappedCount < properties.length && ( +

+ {mappedCount} / {properties.length} ilanın koordinatı var +

+ )} +
+
+ {/* View toggle */} +
+ + +
+ +
-
- - - - Başlık - Tip - Tür - Şehir - Fiyat - Durum - - - - - {properties.length === 0 && ( + {/* List view */} + {viewMode === "list" && ( +
+
+ - - Henüz ilan yok. - + Başlık + Tip + Tür + Şehir + Fiyat + Durum + + + + {properties.length === 0 && ( + + + Henüz ilan yok. + + + )} + {properties.map((p) => ( + { if (el) rowRefs.current[p.$id] = el; }} + > + {p.title} + {PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType} + {LISTING_TYPE_LABELS[p.listingType] ?? p.listingType} + {[p.city, p.district].filter(Boolean).join(", ")} + + {p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"} + + + + + + + + + + + + + + Detay + + + openEdit(p)}> + + Düzenle + + handleDelete(p)} + className="text-destructive focus:text-destructive" + > + + Sil + + + + + + ))} + +
+
+ )} + + {/* Map view — split layout */} + {viewMode === "map" && ( +
+ {/* Left: scrollable property cards */} +
+ {properties.length === 0 && ( +

Henüz ilan yok.

)} - {properties.map((p) => ( - - {p.title} - {PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType} - {LISTING_TYPE_LABELS[p.listingType] ?? p.listingType} - {[p.city, p.district].filter(Boolean).join(", ")} - - {p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"} - - - - - - - - - - - - - - Detay - - - openEdit(p)}> - - Düzenle - - handleDelete(p)} - className="text-destructive focus:text-destructive" + {properties.map((p) => { + const coverImageId = parseImageIds(p.imageIds)[0]; + const hasCoords = p.mapLat != null && p.mapLng != null; + const isSelected = selectedId === p.$id; + return ( +
{ if (el) cardRefs.current[p.$id] = el; }} + onClick={() => hasCoords && handleCardClick(p.$id)} + className={`rounded-lg border bg-card overflow-hidden transition-all ${ + hasCoords ? "cursor-pointer hover:shadow-md" : "opacity-60" + } ${isSelected ? "ring-2 ring-primary shadow-md" : ""}`} + > + {/* Cover image */} + {coverImageId && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {p.title} +
+ )} +
+
+

{p.title}

+ +
+

+ {[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")} +

+
+

+ {p.price.toLocaleString("tr-TR")} + {p.currency ?? "TRY"} +

+
+ {p.roomCount && ( + + {p.roomCount} + + )} + {p.netM2 && ( + + {p.netM2}m² + + )} +
+
+
+ {!hasCoords && ( + 📍 Konum yok + )} + e.stopPropagation()} + className="ml-auto text-xs text-primary hover:underline" > - - Sil - - - - - - ))} - - -
+ Detay → + +
+
+
+ ); + })} +
+ + {/* Right: map */} +
+ +
+
+ )} {}} />
); @@ -153,7 +291,7 @@ function StatusBadge({ status }: { status: string }) { kiralandit: "outline", }; return ( - + {PROPERTY_STATUS_LABELS[status as keyof typeof PROPERTY_STATUS_LABELS] ?? status} );