From 3caddff5155b75ee498c8ef8ce3dc31e410ff2ad Mon Sep 17 00:00:00 2001 From: egecankomur Date: Tue, 5 May 2026 20:32:45 +0300 Subject: [PATCH] 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 --- package.json | 1 + pnpm-lock.yaml | 189 ++++++++++++++++ src/app/(dashboard)/properties/[id]/page.tsx | 24 ++ src/app/sunum/[token]/page.tsx | 11 + .../map/property-map-picker-inner.tsx | 210 ++++++++++++++++++ src/components/map/property-map-picker.tsx | 32 +++ .../map/property-map-view-inner.tsx | 50 +++++ src/components/map/property-map-view.tsx | 20 ++ .../properties/property-form-sheet.tsx | 49 +++- 9 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 src/components/map/property-map-picker-inner.tsx create mode 100644 src/components/map/property-map-picker.tsx create mode 100644 src/components/map/property-map-view-inner.tsx create mode 100644 src/components/map/property-map-view.tsx diff --git a/package.json b/package.json index 50a4cc8..9c7065f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.562.0", + "maplibre-gl": "^5.24.0", "next": "16.1.1", "next-themes": "^0.4.6", "node-appwrite": "^23.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a810445..434c782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 next: specifier: 16.1.1 version: 16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -546,6 +549,42 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.2.0': + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/geojson-vt@5.0.4': + resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} + + '@maplibre/geojson-vt@6.1.0': + resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==} + + '@maplibre/maplibre-gl-style-spec@24.8.4': + resolution: {integrity: sha512-kvtUcthzQGn7nzwiwIggB7lzHbXIprMboLdsem8yNCRIZluyxs7aNzvMHgmdl/lAuX8bkGrSUMiy/lIBysajmg==} + hasBin: true + + '@maplibre/mlt@1.1.9': + resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==} + + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1366,6 +1405,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1383,6 +1425,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -1826,6 +1871,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -2086,6 +2134,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2317,6 +2368,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -2330,6 +2384,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2440,6 +2497,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + maplibre-gl@5.24.0: + resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2465,6 +2526,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2577,6 +2641,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2600,6 +2668,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2607,6 +2678,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2614,6 +2688,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + react-day-picker@9.13.0: resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} engines: {node: '>=18'} @@ -2723,6 +2800,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -2867,6 +2947,9 @@ packages: babel-plugin-macros: optional: true + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2895,6 +2978,9 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3407,6 +3493,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.2.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/geojson-vt@5.0.4': {} + + '@maplibre/geojson-vt@6.1.0': + dependencies: + kdbush: 4.0.2 + + '@maplibre/maplibre-gl-style-spec@24.8.4': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.9': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.3.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@mapbox/vector-tile': 2.0.4 + '@maplibre/geojson-vt': 5.0.4 + '@types/geojson': 7946.0.16 + '@types/supercluster': 7.1.3 + pbf: 4.0.1 + supercluster: 8.0.1 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -4196,6 +4327,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -4212,6 +4345,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/use-sync-external-store@0.0.6': {} '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': @@ -4663,6 +4800,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + electron-to-chromium@1.5.267: {} emoji-regex@9.2.2: {} @@ -5079,6 +5218,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5296,6 +5437,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -5309,6 +5452,8 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + kdbush@4.0.2: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5395,6 +5540,28 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + maplibre-gl@5.24.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.2.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 6.1.0 + '@maplibre/maplibre-gl-style-spec': 24.8.4 + '@maplibre/mlt': 1.1.9 + '@maplibre/vt-pbf': 4.3.0 + '@types/geojson': 7946.0.16 + earcut: 3.0.2 + gl-matrix: 3.4.4 + kdbush: 4.0.2 + murmurhash-js: 1.0.0 + pbf: 4.0.1 + potpack: 2.1.0 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -5416,6 +5583,8 @@ snapshots: ms@2.1.3: {} + murmurhash-js@1.0.0: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -5535,6 +5704,10 @@ snapshots: path-parse@1.0.7: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5555,6 +5728,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -5563,10 +5738,14 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protocol-buffers-schema@3.6.1: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} + quickselect@3.0.0: {} + react-day-picker@9.13.0(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 @@ -5682,6 +5861,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -5893,6 +6076,10 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5916,6 +6103,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyqueue@3.0.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/src/app/(dashboard)/properties/[id]/page.tsx b/src/app/(dashboard)/properties/[id]/page.tsx index 7dd5431..7a64c81 100644 --- a/src/app/(dashboard)/properties/[id]/page.tsx +++ b/src/app/(dashboard)/properties/[id]/page.tsx @@ -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} )} + + {property.mapLat != null && property.mapLng != null && ( + + Google Maps'te aç ↗ + + )} {property.description && ( @@ -177,6 +189,18 @@ export default async function PropertyDetailPage({ params }: Props) { )} + {property.mapLat != null && property.mapLng != null && ( +
+

Konum

+ +
+ )} + {/* Activities */} {activities.length > 0 && (
diff --git a/src/app/sunum/[token]/page.tsx b/src/app/sunum/[token]/page.tsx index 7881745..9cb4108 100644 --- a/src/app/sunum/[token]/page.tsx +++ b/src/app/sunum/[token]/page.tsx @@ -144,6 +144,17 @@ function PropertyCard({ property: p }: { property: Property }) { {p.description && (

{p.description}

)} + + {p.mapLat != null && p.mapLng != null && ( + + 📍 Haritada gör + + )}
); diff --git a/src/components/map/property-map-picker-inner.tsx b/src/components/map/property-map-picker-inner.tsx new file mode 100644 index 0000000..e39dc13 --- /dev/null +++ b/src/components/map/property-map-picker-inner.tsx @@ -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(null); + const mapRef = useRef(null); + const markerRef = useRef(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 ( +
+ {/* Search bar */} +
+
+ + { + setSearch(e.target.value); + setNotFound(false); + }} + /> +
+ +
+ + {notFound && ( +

+ Adres bulunamadı — farklı bir arama deneyin. +

+ )} + + {/* Map */} +
+ + {/* Coordinate display / clear */} +
+
+ + {coords ? ( + + {coords.lat.toFixed(5)}, {coords.lng.toFixed(5)} + + ) : ( + Haritaya tıklayın veya adres arayın + )} +
+ {coords && ( + + )} +
+
+ ); +} diff --git a/src/components/map/property-map-picker.tsx b/src/components/map/property-map-picker.tsx new file mode 100644 index 0000000..a8cae7a --- /dev/null +++ b/src/components/map/property-map-picker.tsx @@ -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: () => ( +
+
+ + Harita yükleniyor... +
+
+ ), + }, +); + +interface Props { + initialLat?: number | null; + initialLng?: number | null; + initialSearchQuery?: string; + onLocationChange: (lat: number, lng: number) => void; + onClear: () => void; +} + +export function PropertyMapPicker(props: Props) { + return ; +} diff --git a/src/components/map/property-map-view-inner.tsx b/src/components/map/property-map-view-inner.tsx new file mode 100644 index 0000000..a7c79b2 --- /dev/null +++ b/src/components/map/property-map-view-inner.tsx @@ -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(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 ( +
+ ); +} diff --git a/src/components/map/property-map-view.tsx b/src/components/map/property-map-view.tsx new file mode 100644 index 0000000..d606282 --- /dev/null +++ b/src/components/map/property-map-view.tsx @@ -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 ; +} diff --git a/src/components/properties/property-form-sheet.tsx b/src/components/properties/property-form-sheet.tsx index b768af3..b20e60a 100644 --- a/src/components/properties/property-form-sheet.tsx +++ b/src/components/properties/property-form-sheet.tsx @@ -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(property?.mapLat ?? null); + const [mapLng, setMapLng] = useState(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 ( @@ -58,6 +76,10 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
+ {/* Hidden lat/lng — updated by map picker */} + + +
@@ -165,6 +187,29 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P