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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user