8849bd5459
- 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
1139 lines
44 KiB
TypeScript
1139 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { useAuth } from "@/lib/AuthContext";
|
|
import { useRouter } from "next/navigation";
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
MapPin,
|
|
Search,
|
|
Heart,
|
|
X,
|
|
Star,
|
|
ChevronRight,
|
|
Sparkles,
|
|
Crown,
|
|
Loader2,
|
|
Plus,
|
|
Trash2,
|
|
Send,
|
|
Navigation,
|
|
} from "lucide-react";
|
|
|
|
// Leaflet CSS — safe in client component
|
|
import "leaflet/dist/leaflet.css";
|
|
|
|
// ─── Dynamic react-leaflet imports (SSR safe) ──────────────
|
|
const LeafletMapContainer = dynamic(
|
|
() => import("react-leaflet").then((m) => m.MapContainer),
|
|
{ ssr: false }
|
|
);
|
|
const TileLayer = dynamic(
|
|
() => import("react-leaflet").then((m) => m.TileLayer),
|
|
{ ssr: false }
|
|
);
|
|
const Marker = dynamic(
|
|
() => import("react-leaflet").then((m) => m.Marker),
|
|
{ ssr: false }
|
|
);
|
|
const Popup = dynamic(
|
|
() => import("react-leaflet").then((m) => m.Popup),
|
|
{ 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;
|
|
name: string;
|
|
address: string;
|
|
lat: number;
|
|
lng: number;
|
|
rating: number;
|
|
type: "mosque" | "restaurant" | "cafe";
|
|
halal_certified: boolean;
|
|
distance?: number;
|
|
source?: string;
|
|
}
|
|
|
|
interface UsageInfo {
|
|
queriesUsed: number;
|
|
queriesLimit: number;
|
|
remaining: number;
|
|
tier: string;
|
|
scope: string;
|
|
}
|
|
|
|
interface Bookmark {
|
|
id: string;
|
|
itemId: string;
|
|
itemType: string;
|
|
label: string | null;
|
|
createdAt: string;
|
|
place: Place | null;
|
|
}
|
|
|
|
interface UserLocation {
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
// ─── Haversine distance (km) ────────────────────────────────
|
|
function haversine(
|
|
lat1: number,
|
|
lng1: number,
|
|
lat2: number,
|
|
lng2: number
|
|
): number {
|
|
const R = 6371; // Earth's radius in km
|
|
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 R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
// ─── Constants ──────────────────────────────────────────────
|
|
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
all: "All",
|
|
mosque: "Mosques",
|
|
restaurant: "Restaurants",
|
|
cafe: "Cafes",
|
|
};
|
|
|
|
const DEFAULT_CENTER: [number, number] = [3.139, 101.6869]; // Kuala Lumpur
|
|
|
|
// ─── Page ───────────────────────────────────────────────────
|
|
export default function HalalMonitorPage() {
|
|
const { user, token, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [places, setPlaces] = useState<Place[]>([]);
|
|
const [usage, setUsage] = useState<UsageInfo | null>(null);
|
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
|
const [search, setSearch] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
|
const [pageLoading, setPageLoading] = useState(true);
|
|
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;
|
|
|
|
// ── Initialize Leaflet custom icons ───────────────────────
|
|
useEffect(() => {
|
|
(async () => {
|
|
const L = await import("leaflet");
|
|
// Fix default icon paths for bundlers
|
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
L.Icon.Default.mergeOptions({
|
|
iconRetinaUrl:
|
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png",
|
|
iconUrl:
|
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
|
|
shadowUrl:
|
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
|
|
});
|
|
|
|
setIcons({
|
|
mosque: L.divIcon({
|
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(16,185,129,0.9);border:2px solid rgba(110,231,183,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🕌</div>`,
|
|
className: "",
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 36],
|
|
popupAnchor: [0, -36],
|
|
}),
|
|
restaurant: L.divIcon({
|
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🍽️</div>`,
|
|
className: "",
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 36],
|
|
popupAnchor: [0, -36],
|
|
}),
|
|
cafe: L.divIcon({
|
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">☕</div>`,
|
|
className: "",
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 36],
|
|
popupAnchor: [0, -36],
|
|
}),
|
|
user: L.divIcon({
|
|
html: `<div style="width:20px;height:20px;border-radius:50%;background:#3b82f6;border:3px solid white;box-shadow:0 0 0 3px rgba(59,130,246,0.4)"></div>`,
|
|
className: "",
|
|
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],
|
|
}),
|
|
});
|
|
})();
|
|
}, []);
|
|
|
|
// ── GeoLocation ───────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!navigator.geolocation) {
|
|
setLocationError("Geolocation is not supported by your browser");
|
|
return;
|
|
}
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
setUserLocation({
|
|
lat: pos.coords.latitude,
|
|
lng: pos.coords.longitude,
|
|
});
|
|
setLocationError(null);
|
|
},
|
|
(err) => {
|
|
setLocationError(err.message);
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 }
|
|
);
|
|
}, []);
|
|
|
|
// ── Redirect if not logged in ─────────────────────────────
|
|
useEffect(() => {
|
|
if (!loading && !token) router.push("/auth");
|
|
}, [loading, token, router]);
|
|
|
|
// ── Fetch places ──────────────────────────────────────────
|
|
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
|
try {
|
|
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, userLocation]);
|
|
|
|
// ── Fetch usage ───────────────────────────────────────────
|
|
const fetchUsage = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch("/mobile/api/halal/usage", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setUsage(data);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [token]);
|
|
|
|
// ── Fetch bookmarks ───────────────────────────────────────
|
|
const fetchBookmarks = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch("/mobile/api/halal/bookmarks", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setBookmarks(data.bookmarks);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [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;
|
|
setUsageLoading(true);
|
|
try {
|
|
const res = await fetch("/mobile/api/halal/usage", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setUsage((prev) => (prev ? { ...prev, ...data } : data));
|
|
} else if (res.status === 429) {
|
|
setUsage((prev) => (prev ? { ...prev, remaining: 0 } : prev));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setUsageLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
// ── Initial load ──────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (loading || !token) return;
|
|
const init = async () => {
|
|
setPageLoading(true);
|
|
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(() => {
|
|
const allPlaces = [...places, ...userPlaces];
|
|
if (!allPlaces.length) return [];
|
|
const withDistances = allPlaces.map((p) => {
|
|
if (userLocation) {
|
|
const dist = haversine(
|
|
userLocation.lat,
|
|
userLocation.lng,
|
|
p.lat,
|
|
p.lng
|
|
);
|
|
return { ...p, distance: Math.round(dist * 100) / 100 };
|
|
}
|
|
return p;
|
|
});
|
|
if (userLocation) {
|
|
return [...withDistances].sort(
|
|
(a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)
|
|
);
|
|
}
|
|
return withDistances;
|
|
}, [places, userPlaces, userLocation]);
|
|
|
|
// Free tier shows ALL place types (rate-limiting stays on query tracking)
|
|
const visiblePlaces = sortedPlaces;
|
|
|
|
// ── Search / filter triggers ──────────────────────────────
|
|
const handleSearch = useCallback(() => {
|
|
fetchPlaces(search, typeFilter);
|
|
if (!isFree) trackQuery();
|
|
}, [search, typeFilter, fetchPlaces, isFree, trackQuery]);
|
|
|
|
const handleTypeFilter = useCallback(
|
|
(type: string) => {
|
|
setTypeFilter(type);
|
|
fetchPlaces(search, type);
|
|
if (!isFree) trackQuery();
|
|
},
|
|
[search, fetchPlaces, isFree, trackQuery]
|
|
);
|
|
|
|
// ── Bookmark toggle ───────────────────────────────────────
|
|
const toggleBookmark = useCallback(
|
|
async (place: Place) => {
|
|
if (!token) return;
|
|
const existing = bookmarks.find((b) => b.itemId === place.id);
|
|
if (existing) {
|
|
const res = await fetch(
|
|
`/mobile/api/halal/bookmarks?id=${existing.id}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}
|
|
);
|
|
if (res.ok) fetchBookmarks();
|
|
} else {
|
|
const res = await fetch("/mobile/api/halal/bookmarks", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
itemId: place.id,
|
|
itemType: place.type,
|
|
label: place.name,
|
|
}),
|
|
});
|
|
if (res.ok) fetchBookmarks();
|
|
}
|
|
},
|
|
[token, bookmarks, fetchBookmarks]
|
|
);
|
|
|
|
const isBookmarked = useCallback(
|
|
(placeId: string) => bookmarks.some((b) => b.itemId === placeId),
|
|
[bookmarks]
|
|
);
|
|
|
|
// ── Render stars ──────────────────────────────────────────
|
|
const renderStars = (rating: number) => {
|
|
const full = Math.floor(rating);
|
|
const half = rating - full >= 0.5;
|
|
const empty = 5 - full - (half ? 1 : 0);
|
|
return (
|
|
<span className="inline-flex items-center gap-0.5">
|
|
{Array.from({ length: full }, (_, i) => (
|
|
<Star
|
|
key={`full-${i}`}
|
|
size={12}
|
|
className="fill-amber-400 text-amber-400"
|
|
/>
|
|
))}
|
|
{half && (
|
|
<div className="relative">
|
|
<Star size={12} className="text-gray-600" />
|
|
<Star
|
|
size={12}
|
|
className="absolute inset-0 fill-amber-400 text-amber-400"
|
|
style={{ clipPath: "inset(0 50% 0 0)" }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{Array.from({ length: empty }, (_, i) => (
|
|
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
|
|
))}
|
|
<span className="text-xs text-gray-400 ml-1">
|
|
{rating.toFixed(1)}
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ── Map center ────────────────────────────────────────────
|
|
const mapCenter: [number, number] = userLocation
|
|
? [userLocation.lat, userLocation.lng]
|
|
: DEFAULT_CENTER;
|
|
|
|
// ── Premium Gating Overlay ─────────────────────────────────
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
|
<div className="w-8 h-8 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
|
{/* ── Header ───────────────────────────────────────── */}
|
|
<div className="px-4 pt-6 pb-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
|
<MapPin size={20} className="text-emerald-400" />
|
|
Halal Monitor
|
|
</h1>
|
|
<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>
|
|
<p className="text-xs font-semibold text-gray-300">
|
|
{usage.remaining}/{usage.queriesLimit}
|
|
</p>
|
|
</div>
|
|
<div className="w-16 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{
|
|
width: `${Math.min(
|
|
100,
|
|
((usage?.queriesUsed ?? 0) /
|
|
(usage?.queriesLimit ?? 10)) *
|
|
100
|
|
)}%`,
|
|
backgroundColor:
|
|
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
Discover halal-certified places around you
|
|
</p>
|
|
|
|
{/* ── Premium Upgrade Banner ─────────────────────── */}
|
|
{isFree && (
|
|
<button
|
|
onClick={() => router.push("/upgrade")}
|
|
className="mt-3 flex items-center justify-between glass-gold rounded-2xl px-4 py-3 w-full active:opacity-80 transition"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
|
<Sparkles size={16} className="text-emerald-400" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-sm font-semibold text-emerald-400">
|
|
Upgrade for Unlimited
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Unlock unlimited queries
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight size={16} className="text-emerald-400" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Free user scope notice */}
|
|
{isFree && (
|
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
|
|
<Crown size={12} />
|
|
Free tier: 10 queries/day
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Search Bar ───────────────────────────────────── */}
|
|
<div className="px-4 mb-3">
|
|
<div className="flex items-center gap-2 bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-3">
|
|
<Search size={16} className="text-gray-500 shrink-0" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search halal places..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
|
/>
|
|
{search && (
|
|
<button
|
|
onClick={() => {
|
|
setSearch("");
|
|
fetchPlaces("", typeFilter);
|
|
}}
|
|
>
|
|
<X size={14} className="text-gray-500" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Category Filter ──────────────────────────────── */}
|
|
<div className="px-4 mb-4 overflow-x-auto">
|
|
<div className="flex gap-2 min-w-max pb-1">
|
|
{TYPES.map((t) => {
|
|
const active = typeFilter === t;
|
|
// All filter buttons enabled for free tier (rate limiting stays)
|
|
const isDisabled = false;
|
|
return (
|
|
<button
|
|
key={t}
|
|
disabled={isDisabled}
|
|
onClick={() => handleTypeFilter(t)}
|
|
className={`px-3.5 py-1.5 rounded-full text-xs font-medium transition-all shrink-0 ${
|
|
active
|
|
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
|
: isDisabled
|
|
? "bg-gray-900 text-gray-600 border border-gray-800/40 cursor-not-allowed opacity-50"
|
|
: "bg-[#111118] text-gray-400 border border-gray-800/60 hover:border-gray-700"
|
|
}`}
|
|
>
|
|
{TYPE_LABELS[t]}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Leaflet Map ──────────────────────────────────── */}
|
|
<div className="mx-4 mb-4">
|
|
<div className="w-full h-48 rounded-2xl overflow-hidden border border-gray-800/60">
|
|
<LeafletMapContainer
|
|
center={mapCenter}
|
|
zoom={13}
|
|
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"
|
|
/>
|
|
{/* User location blue dot */}
|
|
{icons && userLocation && (
|
|
<Marker
|
|
position={[userLocation.lat, userLocation.lng]}
|
|
icon={icons.user}
|
|
>
|
|
<Popup>You are here</Popup>
|
|
</Marker>
|
|
)}
|
|
{/* Place markers */}
|
|
{icons &&
|
|
visiblePlaces.map((place) => (
|
|
<Marker
|
|
key={place.id}
|
|
position={[place.lat, place.lng]}
|
|
icon={place.source === "user" ? icons.userPlace : icons[place.type]}
|
|
eventHandlers={{
|
|
click: () => setSelectedPlace(place),
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div className="text-sm font-semibold">{place.name}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{place.address}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
))}
|
|
</LeafletMapContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Results List ─────────────────────────────────── */}
|
|
<div className="px-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<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" />
|
|
)}
|
|
</div>
|
|
|
|
{pageLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 size={24} className="animate-spin text-emerald-500/50" />
|
|
</div>
|
|
) : 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">Location unavailable</p>
|
|
<p className="text-xs text-gray-700 mt-1">
|
|
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>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{visiblePlaces.map((place) => {
|
|
const bookmarked = isBookmarked(place.id);
|
|
return (
|
|
<div
|
|
key={place.id}
|
|
onClick={() => setSelectedPlace(place)}
|
|
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition cursor-pointer"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
{/* Place name + type badge */}
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="text-sm font-semibold text-white truncate">
|
|
{place.name}
|
|
</h3>
|
|
<span
|
|
className={`text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ${
|
|
place.type === "mosque"
|
|
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
|
|
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
|
}`}
|
|
>
|
|
{place.type === "mosque"
|
|
? "Mosque"
|
|
: place.type === "restaurant"
|
|
? "Restaurant"
|
|
: "Cafe"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Address */}
|
|
<p className="text-xs text-gray-500 mb-1.5 truncate">
|
|
{place.address}
|
|
</p>
|
|
|
|
{/* Rating */}
|
|
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
|
|
|
{/* Distance & Halal badge */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{place.distance !== undefined && (
|
|
<span className="text-xs text-blue-400/80">
|
|
{place.distance < 1
|
|
? `${(place.distance * 1000).toFixed(0)} m`
|
|
: `${place.distance.toFixed(1)} km`}
|
|
</span>
|
|
)}
|
|
{place.halal_certified && (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
|
Halal Certified
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bookmark heart */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleBookmark(place);
|
|
}}
|
|
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition ${
|
|
bookmarked
|
|
? "bg-emerald-500/20 text-emerald-400"
|
|
: "bg-gray-800/60 text-gray-600 hover:text-gray-400"
|
|
}`}
|
|
>
|
|
<Heart
|
|
size={14}
|
|
className={bookmarked ? "fill-emerald-400" : ""}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Detail Modal ──────────────────────────────────── */}
|
|
{selectedPlace && (
|
|
<div
|
|
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
|
|
onClick={() => setSelectedPlace(null)}
|
|
>
|
|
<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"
|
|
>
|
|
{/* Handle */}
|
|
<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 justify-end mb-2">
|
|
<button
|
|
onClick={() => setSelectedPlace(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>
|
|
|
|
{/* Type badge */}
|
|
<div className="mb-3">
|
|
<span
|
|
className={`text-xs font-medium px-3 py-1 rounded-full ${
|
|
selectedPlace.type === "mosque"
|
|
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"
|
|
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
|
}`}
|
|
>
|
|
{selectedPlace.type === "mosque"
|
|
? "🕌 Mosque"
|
|
: selectedPlace.type === "restaurant"
|
|
? "🍽️ Restaurant"
|
|
: "☕ Cafe"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<h2 className="text-lg font-bold text-white mb-1">
|
|
{selectedPlace.name}
|
|
</h2>
|
|
|
|
{/* Halal certified */}
|
|
{selectedPlace.halal_certified && (
|
|
<div className="flex items-center gap-1.5 mb-3">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
<span className="text-xs font-medium text-emerald-400">
|
|
Halal Certified
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Address */}
|
|
<div className="flex items-start gap-2 mb-3">
|
|
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
|
|
<p className="text-sm text-gray-400">
|
|
{selectedPlace.address}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Rating */}
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Star size={14} className="fill-amber-400 text-amber-400" />
|
|
<span className="text-sm font-semibold text-white">
|
|
{selectedPlace.rating.toFixed(1)}
|
|
</span>
|
|
<span className="text-xs text-gray-600">/ 5.0</span>
|
|
</div>
|
|
|
|
{/* Distance (if available) */}
|
|
{selectedPlace.distance !== undefined && (
|
|
<div className="flex items-center gap-2 mb-4 text-sm text-blue-400">
|
|
<MapPin size={14} />
|
|
<span>
|
|
{selectedPlace.distance < 1
|
|
? `${(selectedPlace.distance * 1000).toFixed(0)} m`
|
|
: `${selectedPlace.distance.toFixed(1)} km`}{" "}
|
|
from your location
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Coords */}
|
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
|
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
|
<p className="text-xs text-gray-600">Latitude</p>
|
|
<p className="text-xs text-gray-300 font-mono">
|
|
{selectedPlace.lat.toFixed(4)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
|
<p className="text-xs text-gray-600">Longitude</p>
|
|
<p className="text-xs text-gray-300 font-mono">
|
|
{selectedPlace.lng.toFixed(4)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Open in Google Maps */}
|
|
<a
|
|
href={`https://www.google.com/maps/dir/?api=1&destination=${selectedPlace.lat},${selectedPlace.lng}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 mb-3 bg-blue-500/20 text-blue-300 border border-blue-500/40 hover:bg-blue-500/30"
|
|
>
|
|
<MapPin size={16} />
|
|
Open in Google Maps
|
|
</a>
|
|
|
|
{/* Bookmark button */}
|
|
<button
|
|
onClick={() => {
|
|
toggleBookmark(selectedPlace);
|
|
setSelectedPlace(null);
|
|
}}
|
|
className={`w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 ${
|
|
isBookmarked(selectedPlace.id)
|
|
? "bg-gray-800 text-gray-400 border border-gray-700"
|
|
: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
|
}`}
|
|
>
|
|
<Heart
|
|
size={16}
|
|
className={
|
|
isBookmarked(selectedPlace.id) ? "fill-emerald-400" : ""
|
|
}
|
|
/>
|
|
{isBookmarked(selectedPlace.id)
|
|
? "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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|