feat: add MapLibre GL map to property form and detail page

- Install maplibre-gl; use OpenFreeMap tiles (no API key)
- PropertyMapPickerInner: address search via Nominatim, draggable
  marker, click-to-place, geolocation, clear button
- PropertyMapPicker/View: dynamic next/dynamic wrappers (ssr: false)
- PropertyMapViewInner: read-only marker view with navigation control
- PropertyFormSheet: hidden mapLat/mapLng inputs, picker renders only
  when sheet is open, resets on property change
- Property detail page: Konum section with PropertyMapView + Google Maps link
- Sunum page: Google Maps deep link on PropertyCard when coordinates exist
This commit is contained in:
egecankomur
2026-05-05 20:32:45 +03:00
parent 1d5ad5f62f
commit 3caddff515
9 changed files with 584 additions and 2 deletions
@@ -21,6 +21,7 @@ import {
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";
interface Props {
params: Promise<{ id: string }>;
@@ -166,6 +167,17 @@ export default async function PropertyDetailPage({ params }: Props) {
{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>
)}
</div>
{property.description && (
@@ -177,6 +189,18 @@ export default async function PropertyDetailPage({ params }: Props) {
</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">
+11
View File
@@ -144,6 +144,17 @@ function PropertyCard({ property: p }: { property: Property }) {
{p.description && (
<p className="text-xs text-gray-500 line-clamp-3">{p.description}</p>
)}
{p.mapLat != null && p.mapLng != null && (
<a
href={`https://www.google.com/maps?q=${p.mapLat},${p.mapLng}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
📍 Haritada gör
</a>
)}
</div>
</div>
);
@@ -0,0 +1,210 @@
"use client";
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { Search, Loader2, MapPin, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
const TURKEY_CENTER: [number, number] = [35.0, 39.0];
const MARKER_COLOR = "#2563eb";
interface Props {
initialLat?: number | null;
initialLng?: number | null;
initialSearchQuery?: string;
onLocationChange: (lat: number, lng: number) => void;
onClear: () => void;
}
export function PropertyMapPickerInner({
initialLat,
initialLng,
initialSearchQuery = "",
onLocationChange,
onClear,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markerRef = useRef<maplibregl.Marker | null>(null);
const placeRef = useRef<(lat: number, lng: number) => void>(() => {});
const [search, setSearch] = useState(initialSearchQuery);
const [geocoding, setGeocoding] = useState(false);
const [notFound, setNotFound] = useState(false);
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
initialLat != null && initialLng != null
? { lat: initialLat, lng: initialLng }
: null,
);
// Keep placeRef current so closures in event handlers always have latest state
placeRef.current = (lat: number, lng: number) => {
setCoords({ lat, lng });
onLocationChange(lat, lng);
if (markerRef.current) {
markerRef.current.setLngLat([lng, lat]);
} else if (mapRef.current) {
const marker = new maplibregl.Marker({ draggable: true, color: MARKER_COLOR })
.setLngLat([lng, lat])
.addTo(mapRef.current);
marker.on("dragend", () => {
const pos = marker.getLngLat();
placeRef.current(pos.lat, pos.lng);
});
markerRef.current = marker;
}
};
useEffect(() => {
if (!containerRef.current) return;
const hasInitial = initialLat != null && initialLng != null;
const map = new maplibregl.Map({
container: containerRef.current,
style: STYLE_URL,
center: hasInitial ? [initialLng!, initialLat!] : TURKEY_CENTER,
zoom: hasInitial ? 14 : 5.5,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(
new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true } }),
"top-right",
);
mapRef.current = map;
if (hasInitial) {
const marker = new maplibregl.Marker({ draggable: true, color: MARKER_COLOR })
.setLngLat([initialLng!, initialLat!])
.addTo(map);
marker.on("dragend", () => {
const pos = marker.getLngLat();
placeRef.current(pos.lat, pos.lng);
});
markerRef.current = marker;
}
map.on("click", (e) => {
placeRef.current(e.lngLat.lat, e.lngLat.lng);
});
return () => {
map.remove();
mapRef.current = null;
markerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
if (!search.trim()) return;
setGeocoding(true);
setNotFound(false);
try {
const q = search.includes("Türkiye") ? search : `${search}, Türkiye`;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`,
{ headers: { "Accept-Language": "tr,en" } },
);
const data = (await res.json()) as { lat: string; lon: string }[];
if (!data.length) {
setNotFound(true);
return;
}
const lat = parseFloat(data[0].lat);
const lng = parseFloat(data[0].lon);
placeRef.current(lat, lng);
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 1000 });
} catch {
setNotFound(true);
} finally {
setGeocoding(false);
}
}
function handleClear() {
markerRef.current?.remove();
markerRef.current = null;
setCoords(null);
onClear();
mapRef.current?.flyTo({ center: TURKEY_CENTER, zoom: 5.5 });
}
return (
<div className="space-y-2">
{/* Search bar */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
<Input
className="pl-8"
placeholder="Adres, mahalle veya şehir ara..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setNotFound(false);
}}
/>
</div>
<Button type="submit" variant="outline" size="icon" disabled={geocoding}>
{geocoding ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
</Button>
</form>
{notFound && (
<p className="text-destructive text-xs">
Adres bulunamadı farklı bir arama deneyin.
</p>
)}
{/* Map */}
<div
ref={containerRef}
className="h-64 w-full overflow-hidden rounded-lg border"
/>
{/* Coordinate display / clear */}
<div className="flex items-center justify-between">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<MapPin className="size-3 shrink-0" />
{coords ? (
<span className="font-mono">
{coords.lat.toFixed(5)}, {coords.lng.toFixed(5)}
</span>
) : (
<span>Haritaya tıklayın veya adres arayın</span>
)}
</div>
{coords && (
<button
type="button"
onClick={handleClear}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
>
<X className="size-3" />
Konumu kaldır
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import dynamic from "next/dynamic";
import { MapPin } from "lucide-react";
const Inner = dynamic(
() =>
import("./property-map-picker-inner").then((m) => m.PropertyMapPickerInner),
{
ssr: false,
loading: () => (
<div className="flex h-64 w-full items-center justify-center rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="size-4 animate-pulse" />
Harita yükleniyor...
</div>
</div>
),
},
);
interface Props {
initialLat?: number | null;
initialLng?: number | null;
initialSearchQuery?: string;
onLocationChange: (lat: number, lng: number) => void;
onClear: () => void;
}
export function PropertyMapPicker(props: Props) {
return <Inner {...props} />;
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
interface Props {
lat: number;
lng: number;
title?: string;
className?: string;
}
export function PropertyMapViewInner({ lat, lng, title, className = "" }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: STYLE_URL,
center: [lng, lat],
zoom: 15,
interactive: true,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
const popup = title
? new maplibregl.Popup({ offset: 25, closeButton: false }).setText(title)
: undefined;
new maplibregl.Marker({ color: "#2563eb" })
.setLngLat([lng, lat])
.setPopup(popup)
.addTo(map);
return () => map.remove();
}, [lat, lng, title]);
return (
<div
ref={containerRef}
className={`overflow-hidden rounded-lg border ${className}`}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import dynamic from "next/dynamic";
const Inner = dynamic(
() =>
import("./property-map-view-inner").then((m) => m.PropertyMapViewInner),
{ ssr: false },
);
interface Props {
lat: number;
lng: number;
title?: string;
className?: string;
}
export function PropertyMapView(props: Props) {
return <Inner {...props} />;
}
@@ -1,6 +1,6 @@
"use client";
import { useActionState, useEffect } from "react";
import { useActionState, useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
SheetContent,
@@ -18,6 +17,7 @@ import {
} from "@/components/ui/sheet";
import { createPropertyAction, updatePropertyAction } from "@/lib/appwrite/property-actions";
import { PropertyImageUploader } from "./property-image-uploader";
import { PropertyMapPicker } from "@/components/map/property-map-picker";
import { parseImageIds } from "@/lib/appwrite/storage-utils";
import type { Property } from "@/lib/appwrite/schema";
@@ -37,6 +37,8 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
: createPropertyAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
const [mapLat, setMapLat] = useState<number | null>(property?.mapLat ?? null);
const [mapLng, setMapLng] = useState<number | null>(property?.mapLng ?? null);
useEffect(() => {
if (state.ok) {
@@ -48,8 +50,24 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
}
}, [state]);
// Reset lat/lng when sheet opens for a different property
useEffect(() => {
if (open) {
setMapLat(property?.mapLat ?? null);
setMapLng(property?.mapLng ?? null);
}
}, [open, property]);
const fe = state.fieldErrors ?? {};
const initialSearchQuery = [
property?.neighborhood,
property?.district,
property?.city,
]
.filter(Boolean)
.join(", ");
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
@@ -58,6 +76,10 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
</SheetHeader>
<form action={formAction} className="mt-4 space-y-4 pb-6">
{/* Hidden lat/lng — updated by map picker */}
<input type="hidden" name="mapLat" value={mapLat ?? ""} />
<input type="hidden" name="mapLng" value={mapLng ?? ""} />
<div className="grid gap-1.5">
<Label htmlFor="title">Başlık *</Label>
<Input id="title" name="title" defaultValue={property?.title} placeholder="3+1 Daire, Kadıköy" />
@@ -165,6 +187,29 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
<Textarea id="address" name="address" rows={2} defaultValue={property?.address ?? ""} placeholder="Sokak, kapı no..." />
</div>
{/* Map picker */}
<div className="grid gap-1.5">
<Label>Konum (harita)</Label>
<p className="text-muted-foreground text-xs">
Adres arayın veya haritaya tıklayarak pin bırakın. Sürükleyerek hassaslaştırabilirsiniz.
</p>
{open && (
<PropertyMapPicker
initialLat={mapLat}
initialLng={mapLng}
initialSearchQuery={initialSearchQuery}
onLocationChange={(lat, lng) => {
setMapLat(lat);
setMapLng(lng);
}}
onClear={() => {
setMapLat(null);
setMapLng(null);
}}
/>
)}
</div>
<div className="grid gap-1.5">
<Label htmlFor="description">Açıklama</Label>
<Textarea id="description" name="description" rows={3} defaultValue={property?.description ?? ""} placeholder="İlan detayları..." />