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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export default function DhikrPage() {
|
||||
${active ? `bg-gradient-to-br ${c.from} ${c.to} border-${c.accent}-500/50` : "bg-[#111118] border-gray-800/60"}`}>
|
||||
<p className={`text-sm font-semibold ${active ? "text-white" : "text-gray-400"}`}>{p.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${active ? "text-gray-300" : "text-gray-600"}`}>
|
||||
{active ? `0/${p.target}` : `/${p.target}`}
|
||||
{active ? `${count}/${p.target}` : `/${p.target}`}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
|
||||
+323
-15
@@ -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='© <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>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Halal Places — Overpass API (OpenStreetMap) integration
|
||||
*
|
||||
* Free, no API key, real worldwide data from OSM contributors.
|
||||
* Uses bounding-box queries (faster than radius) with multiple mirror fallbacks.
|
||||
*
|
||||
* Server-side caching via JSON file to stay within Overpass rate limits.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import { URL } from "url";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface HalalPlace {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
rating: number;
|
||||
type: "mosque" | "restaurant" | "cafe";
|
||||
halal_certified: boolean;
|
||||
distance?: number;
|
||||
source: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
opening_hours?: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: HalalPlace[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────
|
||||
|
||||
const OVERPASS_MIRRORS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://lz4.overpass-api.de/api/interpreter",
|
||||
"https://z.overpass-api.de/api/interpreter",
|
||||
];
|
||||
|
||||
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
|
||||
const CACHE_DIR = "/app/data/cache";
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const MAX_PLACES = 50;
|
||||
const QUERY_TIMEOUT = 15; // seconds
|
||||
|
||||
let cacheReady = false;
|
||||
|
||||
function ensureCacheDir() {
|
||||
if (cacheReady) return;
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
cacheReady = true;
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cache helpers ─────────────────────────────────────────
|
||||
|
||||
function cacheKey(lat: number, lng: number, radiusM: number): string {
|
||||
// Round to ~0.25° grid for broader cache hits
|
||||
const gridLat = Math.round(lat * 4) / 4;
|
||||
const gridLng = Math.round(lng * 4) / 4;
|
||||
const r = Math.round(radiusM / 500) * 500;
|
||||
return `places_${gridLat}_${gridLng}_${r}.json`;
|
||||
}
|
||||
|
||||
function cacheRead(key: string): HalalPlace[] | null {
|
||||
try {
|
||||
ensureCacheDir();
|
||||
const filePath = path.join(CACHE_DIR, key);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const entry: CacheEntry = JSON.parse(raw);
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
fs.unlinkSync(filePath);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheWrite(key: string, data: HalalPlace[]) {
|
||||
try {
|
||||
ensureCacheDir();
|
||||
const filePath = path.join(CACHE_DIR, key);
|
||||
const entry: CacheEntry = { data, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8");
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Geocode city → lat/lng via Nominatim ──────────────────
|
||||
|
||||
interface GeocodeResult {
|
||||
lat: number;
|
||||
lng: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
async function geocodeCity(city: string, country?: string): Promise<GeocodeResult | null> {
|
||||
try {
|
||||
const query = country ? `${city}, ${country}` : city;
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: "json",
|
||||
limit: "1",
|
||||
"accept-language": "en",
|
||||
});
|
||||
|
||||
const res = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
|
||||
headers: { "User-Agent": "FalahOS/1.0 (halal-monitor)" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data) || data.length === 0) return null;
|
||||
|
||||
return {
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon),
|
||||
displayName: data[0].display_name,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bounding box from center + radius ─────────────────────
|
||||
|
||||
function boundingBox(lat: number, lng: number, radiusM: number) {
|
||||
// 1° lat ≈ 111,320m
|
||||
// 1° lng ≈ 111,320 * cos(lat) m
|
||||
const dLat = radiusM / 111320;
|
||||
const dLng = radiusM / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||
return {
|
||||
south: lat - dLat,
|
||||
north: lat + dLat,
|
||||
west: lng - dLng,
|
||||
east: lng + dLng,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Build Overpass query (bounding box, not radius) ──────
|
||||
|
||||
function buildOverpassQuery(bbox: { south: number; north: number; west: number; east: number }): string {
|
||||
const bb = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
|
||||
|
||||
return `
|
||||
[out:json][timeout:${QUERY_TIMEOUT}];
|
||||
(
|
||||
node(${bb})["amenity"="restaurant"]["diet:halal"="yes"];
|
||||
way(${bb})["amenity"="restaurant"]["diet:halal"="yes"];
|
||||
node(${bb})["amenity"="fast_food"]["diet:halal"="yes"];
|
||||
way(${bb})["amenity"="fast_food"]["diet:halal"="yes"];
|
||||
node(${bb})["amenity"="place_of_worship"]["religion"="muslim"];
|
||||
way(${bb})["amenity"="place_of_worship"]["religion"="muslim"];
|
||||
node(${bb})["amenity"="cafe"]["diet:halal"="yes"];
|
||||
way(${bb})["amenity"="cafe"]["diet:halal"="yes"];
|
||||
);
|
||||
out center 50;
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// ─── Classify OSM element ─────────────────────────────────
|
||||
|
||||
function classifyType(tags: Record<string, string>): "mosque" | "restaurant" | "cafe" | null {
|
||||
const amenity = tags.amenity || "";
|
||||
if (amenity === "place_of_worship" && tags.religion === "muslim") return "mosque";
|
||||
if (amenity === "restaurant" || amenity === "fast_food") return "restaurant";
|
||||
if (amenity === "cafe") return "cafe";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Parse OSM response ────────────────────────────────────
|
||||
|
||||
function parseOsmResponse(data: any): HalalPlace[] {
|
||||
if (!data || !Array.isArray(data.elements)) return [];
|
||||
|
||||
const places: HalalPlace[] = [];
|
||||
|
||||
for (const el of data.elements) {
|
||||
const tags = el.tags || {};
|
||||
const type = classifyType(tags);
|
||||
if (!type) continue;
|
||||
|
||||
let lat = el.lat;
|
||||
let lng = el.lon;
|
||||
if (el.type === "way" && el.center) {
|
||||
lat = el.center.lat;
|
||||
lng = el.center.lon;
|
||||
}
|
||||
if (lat === undefined || lng === undefined) continue;
|
||||
|
||||
const name = tags.name || tags["name:en"] || "";
|
||||
if (!name) continue;
|
||||
|
||||
const addressParts = [
|
||||
tags["addr:housenumber"],
|
||||
tags["addr:street"],
|
||||
tags["addr:city"],
|
||||
tags["addr:postcode"],
|
||||
].filter(Boolean);
|
||||
const address = addressParts.length > 0
|
||||
? addressParts.join(", ")
|
||||
: [tags["addr:city"], tags["addr:country"]].filter(Boolean).join(", ");
|
||||
|
||||
places.push({
|
||||
id: `${type}-${el.type}-${el.id}`,
|
||||
name,
|
||||
address: address || `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
lat,
|
||||
lng,
|
||||
rating: tags.rating ? parseFloat(tags.rating) : 4.0,
|
||||
type,
|
||||
halal_certified: tags["diet:halal"] === "yes" || tags.cuisine === "halal",
|
||||
source: "openstreetmap",
|
||||
phone: tags.phone || tags["contact:phone"] || undefined,
|
||||
website: tags.website || tags["contact:website"] || undefined,
|
||||
opening_hours: tags.opening_hours || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return places;
|
||||
}
|
||||
|
||||
// ─── Query Overpass with mirror fallback ───────────────────
|
||||
|
||||
async function queryOverpass(query: string): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const mirror of OVERPASS_MIRRORS) {
|
||||
try {
|
||||
const url = new URL(mirror);
|
||||
const body = new URLSearchParams({ data: query }).toString();
|
||||
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
Accept: "*/*",
|
||||
"User-Agent": "FalahOS-HalalMonitor/1.0",
|
||||
},
|
||||
timeout: 25000,
|
||||
},
|
||||
(res) => {
|
||||
let chunks: Buffer[] = [];
|
||||
res.on("data", (c: Buffer) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(raw);
|
||||
} else if (raw.includes("rate_limited")) {
|
||||
reject(new Error("RATE_LIMITED"));
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 100)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("TIMEOUT")); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
return JSON.parse(data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message === "RATE_LIMITED") continue;
|
||||
if (err.message === "TIMEOUT" || err.message.includes("504")) {
|
||||
// Retry once on timeout/504 — Overpass can be slow on first query
|
||||
continue;
|
||||
}
|
||||
lastError = err;
|
||||
} else {
|
||||
lastError = new Error(String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) throw lastError;
|
||||
throw new Error("All Overpass mirrors exhausted");
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────
|
||||
|
||||
export async function fetchNearbyPlaces(
|
||||
lat: number,
|
||||
lng: number,
|
||||
radiusM: number = 5000
|
||||
): Promise<HalalPlace[]> {
|
||||
const key = cacheKey(lat, lng, radiusM);
|
||||
|
||||
// Check cache first
|
||||
const cached = cacheRead(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
try {
|
||||
const bbox = boundingBox(lat, lng, radiusM);
|
||||
const query = buildOverpassQuery(bbox);
|
||||
const data = await queryOverpass(query);
|
||||
const places = parseOsmResponse(data).slice(0, MAX_PLACES);
|
||||
|
||||
if (places.length > 0) cacheWrite(key, places);
|
||||
|
||||
return places;
|
||||
} catch (error) {
|
||||
console.error("Overpass fetch failed:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCityPlaces(
|
||||
city: string,
|
||||
country?: string,
|
||||
radiusM: number = 5000
|
||||
): Promise<{ places: HalalPlace[]; location: string | null }> {
|
||||
const geo = await geocodeCity(city, country);
|
||||
if (!geo) {
|
||||
return { places: [], location: null };
|
||||
}
|
||||
|
||||
const places = await fetchNearbyPlaces(geo.lat, geo.lng, radiusM);
|
||||
return { places, location: geo.displayName };
|
||||
}
|
||||
Reference in New Issue
Block a user