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
+84 -68
View File
@@ -1,98 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { haversineDistance } from "@/lib/geo";
import { fetchNearbyPlaces, fetchCityPlaces } from "@/lib/halal-places";
interface Place {
id: string;
name: string;
address: string;
lat: number;
lng: number;
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
distance?: number;
}
const MOCK_PLACES: Place[] = [
// ── Mosques ───────────────────────────────────────────
{ id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
{ id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
{ id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
{ id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
{ id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true },
{ id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true },
{ id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true },
{ id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true },
// ── Restaurants ───────────────────────────────────────
{ id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
{ id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
{ id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
{ id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true },
{ id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true },
{ id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true },
// ── Cafes ─────────────────────────────────────────────
{ id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true },
{ id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true },
{ id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true },
{ id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true },
{ id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true },
];
export const dynamic = "force-dynamic";
export const maxDuration = 30; // Overpass can be slow
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase();
const type = searchParams.get("type")?.toLowerCase();
const q = searchParams.get("q")?.trim();
const latParam = searchParams.get("lat");
const lngParam = searchParams.get("lng");
const city = searchParams.get("city")?.trim();
const country = searchParams.get("country")?.trim();
const type = searchParams.get("type")?.toLowerCase();
const radiusParam = searchParams.get("radius");
let filtered = [...MOCK_PLACES];
const radius = radiusParam ? parseInt(radiusParam, 10) : 5000;
// Filter by type if provided and valid
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
filtered = filtered.filter((p) => p.type === type);
let places;
let searchLocation: string | null = null;
// Priority 1: City search (with optional country)
if (city) {
const result = await fetchCityPlaces(city, country || undefined, radius);
places = result.places;
searchLocation = result.location;
}
// Priority 2: Lat/lng nearby search
else if (latParam && lngParam) {
const lat = parseFloat(latParam);
const lng = parseFloat(lngParam);
if (!isNaN(lat) && !isNaN(lng)) {
places = await fetchNearbyPlaces(lat, lng, radius);
searchLocation = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
} else {
places = [];
}
}
// Priority 3: Text search only (no location) — return empty, need a location
else if (q) {
// Text search without location — return empty with a helpful message
return NextResponse.json({
places: [],
error: "Please enable location or specify a city",
searchLocation: null,
});
}
// No params at all — default to Kuala Lumpur as fallback
else {
const result = await fetchCityPlaces("Kuala Lumpur", "Malaysia", radius);
places = result.places;
searchLocation = result.location;
}
// Apply post-fetch filters
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
places = places.filter((p) => p.type === type);
}
// Filter by search query (name or address)
if (q) {
filtered = filtered.filter(
const lowerQ = q.toLowerCase();
places = places.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address.toLowerCase().includes(q),
p.name.toLowerCase().includes(lowerQ) ||
p.address.toLowerCase().includes(lowerQ)
);
}
// If lat/lng provided, calculate distance and sort by nearest
// Sort by distance if lat/lng provided
if (latParam && lngParam) {
const userLat = parseFloat(latParam);
const userLng = parseFloat(lngParam);
if (!isNaN(userLat) && !isNaN(userLng)) {
filtered = filtered.map((p) => ({
...p,
distance: haversineDistance(userLat, userLng, p.lat, p.lng),
}));
// Sort by distance ascending (nearest first)
filtered.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
places = places
.map((p) => ({
...p,
distance: haversineDistance(userLat, userLng, p.lat, p.lng),
}))
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
}
}
return NextResponse.json({ places: filtered });
return NextResponse.json({
places,
searchLocation,
total: places.length,
});
} catch (error) {
console.error("GET /api/halal/places error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
{ places: [], error: "Unable to fetch halal places right now" },
{ status: 200 } // Return 200 with empty results — graceful degradation
);
}
}
// ─── Client-side haversine (duplicated for SSR-safety) ─────
function haversineDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2;
return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * 100) / 100;
}
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
// ─── GET /api/halal/user-places ─────────────────────────────
// Returns all user-contributed places, optionally within a bounding box
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId"); // optional filter
const swLat = searchParams.get("swLat");
const swLng = searchParams.get("swLng");
const neLat = searchParams.get("neLat");
const neLng = searchParams.get("neLng");
const where: Record<string, unknown> = {};
if (userId) where.userId = userId;
if (swLat && swLng && neLat && neLng) {
where.lat = { gte: parseFloat(swLat), lte: parseFloat(neLat) };
where.lng = { gte: parseFloat(swLng), lte: parseFloat(neLng) };
}
const places = await prisma.userPlace.findMany({
where,
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, name: true } },
},
});
return NextResponse.json({ places });
} catch (error) {
console.error("GET /api/halal/user-places error:", error);
return NextResponse.json(
{ places: [], error: "Failed to fetch user places" },
{ status: 200 }
);
}
}
// ─── POST /api/halal/user-places ────────────────────────────
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { name, address, lat, lng, type, notes } = body;
if (!name || !name.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (lat === undefined || lng === undefined || isNaN(lat) || isNaN(lng)) {
return NextResponse.json({ error: "Valid coordinates are required" }, { status: 400 });
}
if (!["mosque", "restaurant", "cafe"].includes(type)) {
return NextResponse.json({ error: "Type must be mosque, restaurant, or cafe" }, { status: 400 });
}
const place = await prisma.userPlace.create({
data: {
userId: auth.userId,
name: name.trim(),
address: address?.trim() || "",
lat,
lng,
type,
notes: notes?.trim() || null,
},
include: {
user: { select: { id: true, name: true } },
},
});
return NextResponse.json({ place }, { status: 201 });
} catch (error) {
console.error("POST /api/halal/user-places error:", error);
return NextResponse.json(
{ error: "Failed to create place" },
{ status: 500 }
);
}
}
// ─── DELETE /api/halal/user-places?id=xxx ───────────────────
export async function DELETE(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Place ID required" }, { status: 400 });
}
const place = await prisma.userPlace.findUnique({ where: { id } });
if (!place) {
return NextResponse.json({ error: "Place not found" }, { status: 404 });
}
if (place.userId !== auth.userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.userPlace.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error("DELETE /api/halal/user-places error:", error);
return NextResponse.json(
{ error: "Failed to delete place" },
{ status: 500 }
);
}
}