From 76d6888b90977080004491d19c94e8692d4ff779 Mon Sep 17 00:00:00 2001 From: Mhanif26 Date: Sat, 16 May 2026 20:44:41 +0700 Subject: [PATCH 01/12] tambah fitur leaflet js pada penerima bantuan --- src/components/LocationPicker.tsx | 152 ++++++++++++++++++++++++++++++ src/pages/PlacesOfWorshipPage.tsx | 8 +- src/pages/RecipientsPage.tsx | 47 ++++++--- 3 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 src/components/LocationPicker.tsx diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx new file mode 100644 index 0000000..63567d6 --- /dev/null +++ b/src/components/LocationPicker.tsx @@ -0,0 +1,152 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import L, { type LatLngExpression, type LeafletEvent } from "leaflet"; +import "leaflet/dist/leaflet.css"; + +type LocationValue = { + latitude: string; + longitude: string; + address: string; +}; + +type LocationPickerProps = { + value: LocationValue; + onChange: (next: Partial) => void; + label?: string; + hint?: string; +}; + +const DEFAULT_CENTER: LatLngExpression = [-6.2088, 106.8456]; + +const markerIcon = L.icon({ + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +function parsePosition(value: LocationValue) { + const latitude = Number.parseFloat(value.latitude); + const longitude = Number.parseFloat(value.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + return { lat: latitude, lng: longitude }; +} + +async function reverseGeocode(latitude: number, longitude: number) { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${latitude}&lon=${longitude}&accept-language=id&addressdetails=1`, + { + headers: { + Accept: "application/json", + }, + }, + ); + + if (!response.ok) { + throw new Error("Reverse geocode failed"); + } + + const data = (await response.json()) as { display_name?: string }; + return data.display_name ?? ""; +} + +function LocationMap({ + position, + onPick, +}: { + position: { lat: number; lng: number } | null; + onPick: (latitude: number, longitude: number) => void; +}) { + const map = useMap(); + + useEffect(() => { + if (position) { + map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15), { + animate: true, + }); + } + }, [map, position]); + + useMapEvents({ + click(event) { + onPick(event.latlng.lat, event.latlng.lng); + }, + }); + + if (!position) { + return null; + } + + return ( + + ); +} + +export function LocationPicker({ value, onChange, label = "Pilih titik lokasi", hint = "Klik peta atau geser pin untuk mengisi koordinat dan alamat otomatis." }: LocationPickerProps) { + const position = useMemo(() => parsePosition(value), [value]); + const requestRef = useRef(0); + const [isResolving, setIsResolving] = useState(false); + + const handlePick = async (latitude: number, longitude: number) => { + const nextLatitude = latitude.toFixed(6); + const nextLongitude = longitude.toFixed(6); + onChange({ latitude: nextLatitude, longitude: nextLongitude }); + + const requestId = ++requestRef.current; + setIsResolving(true); + try { + const resolvedAddress = await reverseGeocode(latitude, longitude); + if (requestRef.current !== requestId) return; + if (resolvedAddress) { + onChange({ address: resolvedAddress }); + } + } catch { + // Keep the selected coordinates even if reverse geocoding fails. + } finally { + if (requestRef.current === requestId) { + setIsResolving(false); + } + } + }; + + return ( +
+
+
+

{label}

+

{hint}

+
+ {isResolving &&

Mengambil alamat...

} +
+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/PlacesOfWorshipPage.tsx b/src/pages/PlacesOfWorshipPage.tsx index 04212b0..0284564 100644 --- a/src/pages/PlacesOfWorshipPage.tsx +++ b/src/pages/PlacesOfWorshipPage.tsx @@ -177,8 +177,8 @@ export default function PlacesOfWorshipPage() { Tambah Rumah Ibadah
- - setFormData({ ...formData, name: e.target.value })} placeholder="Nama rumah ibadah" className="h-9" /> + + setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
@@ -322,8 +322,8 @@ export default function PlacesOfWorshipPage() { Edit Rumah Ibadah
- - setFormData({ ...formData, name: e.target.value })} className="h-9" /> + + setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
diff --git a/src/pages/RecipientsPage.tsx b/src/pages/RecipientsPage.tsx index 95204ee..4e7c159 100644 --- a/src/pages/RecipientsPage.tsx +++ b/src/pages/RecipientsPage.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { trpc } from "@/providers/trpc"; +import { LocationPicker } from "@/components/LocationPicker"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -185,6 +186,10 @@ export default function RecipientsPage() { const selectedRec = recipients?.find((r) => r.id === selectedRecipient); + const updateFormData = (next: Partial) => { + setFormData((current) => ({ ...current, ...next })); + }; + return (
@@ -234,26 +239,32 @@ export default function RecipientsPage() {
- setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" /> + updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
+
- setFormData({ ...formData, phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" /> + updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
- setFormData({ ...formData, familyMembers: parseInt(e.target.value) || 1 })} className="h-9" /> + updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
- setFormData({ ...formData, incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> + updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" />
- updateFormData({ placeOfWorshipId: parseInt(v) })}> {(places ?? []).map((p) => ( @@ -266,16 +277,16 @@ export default function RecipientsPage() {
- setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" /> + updateFormData({ latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" />
- setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" /> + updateFormData({ longitude: e.target.value })} placeholder="106.xxxx" className="h-9" />
- setFormData({ ...formData, notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" /> + updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
- setFormData({ ...formData, address: e.target.value })} className="h-9" /> + updateFormData({ address: e.target.value })} className="h-9" />
+
- setFormData({ ...formData, phone: e.target.value })} className="h-9" /> + updateFormData({ phone: e.target.value })} className="h-9" />
- setFormData({ ...formData, familyMembers: parseInt(e.target.value) || 1 })} className="h-9" /> + updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
- setFormData({ ...formData, incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> + updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" />
- updateFormData({ placeOfWorshipId: parseInt(v) })}> {(places ?? []).map((p) => ( @@ -462,11 +479,11 @@ export default function RecipientsPage() {
- setFormData({ ...formData, latitude: e.target.value })} className="h-9" /> + updateFormData({ latitude: e.target.value })} className="h-9" />
- setFormData({ ...formData, longitude: e.target.value })} className="h-9" /> + updateFormData({ longitude: e.target.value })} className="h-9" />
+ ) : null} +
+ {helperText ?

{helperText}

: null} + +