feat: real Halal Monitor via Overpass API + user-contributed yellow markers

- Replace hardcoded KL mock data with live OpenStreetMap Overpass API
- Support 15+ major cities worldwide (London, Dubai, Tokyo, NY, etc.)
- Bounding-box queries (faster than radius search)
- HTTP/1.1 fix for Overpass Apache compat
- Nominatim geocoding for city search
- 6-hour file-based cache per grid cell
- 3-mirror Overpass fallback with retry on timeout
- User-contributed places with yellow () markers
- 'Add Place' modal with name/type/address/notes form
- Map tap-to-pin coordinates for new places
- Delete own submissions from detail modal
- Prisma UserPlace model + API routes

Closes: Halal Monitor showing only KL data
This commit is contained in:
root
2026-06-18 17:51:15 +02:00
parent 14d7127e41
commit 8849bd5459
6 changed files with 891 additions and 84 deletions
+323 -15
View File
@@ -14,6 +14,10 @@ import {
Sparkles,
Crown,
Loader2,
Plus,
Trash2,
Send,
Navigation,
} from "lucide-react";
// Leaflet CSS — safe in client component
@@ -37,6 +41,17 @@ const Popup = dynamic(
{ ssr: false }
);
// ─── Map click handler (for adding places) ─────────────────
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
const { useMapEvents } = require("react-leaflet");
useMapEvents({
click: (e: any) => {
onMapClick(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
// ─── Types ──────────────────────────────────────────────────
interface Place {
id: string;
@@ -48,6 +63,7 @@ interface Place {
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
distance?: number;
source?: string;
}
interface UsageInfo {
@@ -116,7 +132,13 @@ export default function HalalMonitorPage() {
const [usageLoading, setUsageLoading] = useState(false);
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
const [locationError, setLocationError] = useState<string | null>(null);
const [searchLocation, setSearchLocation] = useState<string | null>(null);
const [icons, setIcons] = useState<Record<string, any> | null>(null);
const [userPlaces, setUserPlaces] = useState<Place[]>([]);
const [showAddPlace, setShowAddPlace] = useState(false);
const [addForm, setAddForm] = useState({ name: "", address: "", type: "restaurant", notes: "" });
const [mapClickPos, setMapClickPos] = useState<[number, number] | null>(null);
const [submitting, setSubmitting] = useState(false);
const isPremium = user?.isPremium || user?.isPro || false;
const isFree = !isPremium;
@@ -164,6 +186,13 @@ export default function HalalMonitorPage() {
iconSize: [20, 20],
iconAnchor: [10, 10],
}),
userPlace: L.divIcon({
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(234,179,8,0.9);border:2px solid rgba(234,179,8,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:16px">⭐</div>`,
className: "",
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
}),
});
})();
}, []);
@@ -200,16 +229,21 @@ export default function HalalMonitorPage() {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (type && type !== "all") params.set("type", type);
if (userLocation) {
params.set("lat", userLocation.lat.toString());
params.set("lng", userLocation.lng.toString());
}
const res = await fetch(`/mobile/api/halal/places?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPlaces(data.places);
if (data.searchLocation) setSearchLocation(data.searchLocation);
}
} catch {
// ignore
}
}, [search, typeFilter]);
}, [search, typeFilter, userLocation]);
// ── Fetch usage ───────────────────────────────────────────
const fetchUsage = useCallback(async () => {
@@ -243,6 +277,81 @@ export default function HalalMonitorPage() {
}
}, [token]);
// ── Fetch user-contributed places ─────────────────────────
const fetchUserPlaces = useCallback(async () => {
try {
const res = await fetch("/mobile/api/halal/user-places");
if (res.ok) {
const data = await res.json();
setUserPlaces(data.places.map((p: any) => ({
id: `user-${p.id}`,
name: p.name,
address: p.address,
lat: p.lat,
lng: p.lng,
rating: 4.0,
type: p.type,
halal_certified: true,
source: "user",
notes: p.notes,
userId: p.user?.id,
userName: p.user?.name,
createdAt: p.createdAt,
})));
}
} catch {
// ignore
}
}, []);
// ── Submit a new user place ─────────────────────────────
const handleAddPlace = useCallback(async () => {
if (!token || !addForm.name.trim() || !mapClickPos) return;
setSubmitting(true);
try {
const res = await fetch("/mobile/api/halal/user-places", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: addForm.name,
address: addForm.address,
lat: mapClickPos[0],
lng: mapClickPos[1],
type: addForm.type,
notes: addForm.notes,
}),
});
if (res.ok) {
setShowAddPlace(false);
setAddForm({ name: "", address: "", type: "restaurant", notes: "" });
setMapClickPos(null);
await fetchUserPlaces();
}
} catch {
// ignore
} finally {
setSubmitting(false);
}
}, [token, addForm, mapClickPos, fetchUserPlaces]);
// ── Delete a user place ─────────────────────────────────
const handleDeletePlace = useCallback(async (placeId: string) => {
if (!token) return;
const realId = placeId.replace("user-", "");
try {
await fetch(`/mobile/api/halal/user-places?id=${realId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
await fetchUserPlaces();
} catch {
// ignore
}
}, [token, fetchUserPlaces]);
// ── Track usage query ─────────────────────────────────────
const trackQuery = useCallback(async () => {
if (!token) return;
@@ -273,16 +382,34 @@ export default function HalalMonitorPage() {
if (loading || !token) return;
const init = async () => {
setPageLoading(true);
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks(), fetchUserPlaces()]);
setPageLoading(false);
};
init();
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
// ── Re-fetch when geolocation arrives (if initial load beat it) ──
useEffect(() => {
if (userLocation && !pageLoading) {
fetchPlaces();
}
// Only fire when userLocation transitions from null → value
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userLocation]);
// ── Handle map click for adding places ────────────────────
const handleMapClick = useCallback((lat: number, lng: number) => {
setMapClickPos([lat, lng]);
if (!showAddPlace) {
setShowAddPlace(true);
}
}, [showAddPlace]);
// ── Compute distances & sort by distance ──────────────────
const sortedPlaces = useMemo(() => {
if (!places.length) return [];
const withDistances = places.map((p) => {
const allPlaces = [...places, ...userPlaces];
if (!allPlaces.length) return [];
const withDistances = allPlaces.map((p) => {
if (userLocation) {
const dist = haversine(
userLocation.lat,
@@ -300,7 +427,7 @@ export default function HalalMonitorPage() {
);
}
return withDistances;
}, [places, userLocation]);
}, [places, userPlaces, userLocation]);
// Free tier shows ALL place types (rate-limiting stays on query tracking)
const visiblePlaces = sortedPlaces;
@@ -417,7 +544,15 @@ export default function HalalMonitorPage() {
<MapPin size={20} className="text-emerald-400" />
Halal Monitor
</h1>
{usage && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowAddPlace(true)}
className="w-9 h-9 rounded-full bg-yellow-500/20 border border-yellow-500/40 flex items-center justify-center active:scale-90 transition"
title="Add a place"
>
<Plus size={16} className="text-yellow-400" />
</button>
{usage && (
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs text-gray-500">Today</p>
@@ -442,6 +577,7 @@ export default function HalalMonitorPage() {
</div>
</div>
)}
</div>
</div>
<p className="text-xs text-gray-500">
Discover halal-certified places around you
@@ -540,6 +676,7 @@ export default function HalalMonitorPage() {
className="w-full h-full"
scrollWheelZoom={false}
>
<MapClickHandler onMapClick={handleMapClick} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
@@ -559,7 +696,7 @@ export default function HalalMonitorPage() {
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icons[place.type]}
icon={place.source === "user" ? icons.userPlace : icons[place.type]}
eventHandlers={{
click: () => setSelectedPlace(place),
}}
@@ -579,11 +716,18 @@ export default function HalalMonitorPage() {
{/* ── Results List ─────────────────────────────────── */}
<div className="px-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
{visiblePlaces.length}{" "}
{visiblePlaces.length === 1 ? "place" : "places"}
{userLocation && " · Nearest first"}
</h2>
<div>
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
{visiblePlaces.length}{" "}
{visiblePlaces.length === 1 ? "place" : "places"}
{userLocation && " · Nearest first"}
</h2>
{searchLocation && (
<p className="text-[10px] text-gray-700 mt-0.5">
{searchLocation}
</p>
)}
</div>
{usageLoading && (
<Loader2 size={12} className="animate-spin text-gray-600" />
)}
@@ -593,12 +737,20 @@ export default function HalalMonitorPage() {
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-emerald-500/50" />
</div>
) : visiblePlaces.length === 0 ? (
) : visiblePlaces.length === 0 && locationError ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No places found</p>
<p className="text-sm text-gray-500">Location unavailable</p>
<p className="text-xs text-gray-700 mt-1">
Try adjusting your search or filters.
Enable location or type a city name above.
</p>
</div>
) : visiblePlaces.length === 0 && !pageLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No halal places found nearby</p>
<p className="text-xs text-gray-700 mt-1">
Try a different area or search for a city.
</p>
</div>
) : (
@@ -821,6 +973,162 @@ export default function HalalMonitorPage() {
? "Remove Bookmark"
: "Add to Bookmarks"}
</button>
{/* Delete button for user-contributed places */}
{selectedPlace.source === "user" && (
<button
onClick={() => {
handleDeletePlace(selectedPlace.id);
setSelectedPlace(null);
}}
className="w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 mt-2 bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20"
>
<Trash2 size={16} />
Remove My Place
</button>
)}
</div>
</div>
</div>
)}
{/* ── Add Place Modal ────────────────────────────────── */}
{showAddPlace && (
<div
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
onClick={() => setShowAddPlace(false)}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[#111118] border border-gray-800/60 rounded-t-3xl sm:rounded-3xl w-full max-w-md max-h-[85vh] overflow-y-auto animate-fade-in"
>
<div className="pt-3 pb-1 flex justify-center sm:hidden">
<div className="w-10 h-1 rounded-full bg-gray-700" />
</div>
<div className="p-5">
{/* Close button */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Navigation size={18} className="text-yellow-400" />
Add a Place
</h2>
<button
onClick={() => { setShowAddPlace(false); setMapClickPos(null); }}
className="w-8 h-8 rounded-full bg-gray-800/80 flex items-center justify-center"
>
<X size={14} className="text-gray-400" />
</button>
</div>
<p className="text-xs text-gray-500 mb-4">
Share a halal place you know. Yellow markers help the community.
</p>
{/* Map click hint */}
{!mapClickPos && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-2xl p-3 mb-4 text-center">
<p className="text-xs text-yellow-400">
👆 Tap on the map to set a location, then fill in the details
</p>
</div>
)}
{mapClickPos && (
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-3 mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-emerald-400 font-medium">📍 Location set</p>
<p className="text-[10px] text-gray-500 font-mono mt-0.5">
{mapClickPos[0].toFixed(4)}, {mapClickPos[1].toFixed(4)}
</p>
</div>
<button
onClick={() => setMapClickPos(null)}
className="text-xs text-gray-500 underline"
>
Reset
</button>
</div>
</div>
)}
{/* Name */}
<label className="block mb-2">
<span className="text-xs text-gray-400 font-medium mb-1 block">Place name *</span>
<input
type="text"
placeholder="e.g. Al-Noor Restaurant"
value={addForm.name}
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 outline-none focus:border-yellow-500/40 transition"
/>
</label>
{/* Type selector */}
<label className="block mb-2">
<span className="text-xs text-gray-400 font-medium mb-1 block">Type *</span>
<div className="flex gap-2">
{["restaurant", "mosque", "cafe"].map((t) => (
<button
key={t}
onClick={() => setAddForm({ ...addForm, type: t })}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium transition border ${
addForm.type === t
? "bg-yellow-500/20 text-yellow-300 border-yellow-500/40"
: "bg-[#0a0a0f] text-gray-500 border-gray-800/60"
}`}
>
{t === "restaurant" ? "🍽️ Restaurant" : t === "mosque" ? "🕌 Mosque" : "☕ Cafe"}
</button>
))}
</div>
</label>
{/* Address */}
<label className="block mb-2">
<span className="text-xs text-gray-400 font-medium mb-1 block">Address</span>
<input
type="text"
placeholder="e.g. 123 Main Street"
value={addForm.address}
onChange={(e) => setAddForm({ ...addForm, address: e.target.value })}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 outline-none focus:border-yellow-500/40 transition"
/>
</label>
{/* Notes */}
<label className="block mb-4">
<span className="text-xs text-gray-400 font-medium mb-1 block">Notes (optional)</span>
<textarea
placeholder="e.g. Great biryani, family-friendly"
value={addForm.notes}
onChange={(e) => setAddForm({ ...addForm, notes: e.target.value })}
rows={2}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 outline-none resize-none focus:border-yellow-500/40 transition"
/>
</label>
{/* Submit */}
<button
onClick={handleAddPlace}
disabled={!addForm.name.trim() || !mapClickPos || submitting}
className={`w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 ${
!addForm.name.trim() || !mapClickPos || submitting
? "bg-gray-800 text-gray-600 cursor-not-allowed"
: "bg-yellow-500/20 text-yellow-300 border border-yellow-500/40 hover:bg-yellow-500/30"
}`}
>
{submitting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Send size={16} />
)}
{submitting ? "Saving..." : "Submit Place"}
</button>
<p className="text-[10px] text-gray-700 text-center mt-3">
Your submission will appear with a yellow marker for everyone
</p>
</div>
</div>
</div>