Add Prayer Reminder, Dhikr Counter, Qibla Finder, Halal Monitor updates
- Prayer reminder: /prayer page with 5 daily times, countdown, alAdhan API - Dhikr counter: /dhikr page with 3 presets, custom counter, haptics, DB tracking - Qibla finder: /qibla page with compass SVG, DeviceOrientation, bearing to Kaaba - Halal Monitor: Leaflet + Google Places, 26 KL markers, geolocation, distance sort - BottomNav: added clock icon for prayer - Prisma: schema updates for DhikrSession, DhikrDay, DailyStreak, XpTransaction - Geo utility module - Weighted Traefik routing: Contabo 99% / Synology 1% cold standby
This commit is contained in:
+300
-98
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
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,
|
||||
@@ -15,7 +16,28 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
// 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 }
|
||||
);
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
interface Place {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,6 +47,7 @@ interface Place {
|
||||
rating: number;
|
||||
type: "mosque" | "restaurant" | "cafe";
|
||||
halal_certified: boolean;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface UsageInfo {
|
||||
@@ -44,7 +67,30 @@ interface Bookmark {
|
||||
place: Place | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
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",
|
||||
@@ -53,7 +99,9 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
cafe: "Cafes",
|
||||
};
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_CENTER: [number, number] = [3.139, 101.6869]; // Kuala Lumpur
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────
|
||||
export default function HalalMonitorPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -66,20 +114,87 @@ export default function HalalMonitorPage() {
|
||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const markersRef = useRef<any[]>([]);
|
||||
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [icons, setIcons] = useState<Record<string, any> | null>(null);
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro || false;
|
||||
const isFree = !isPremium;
|
||||
|
||||
// ── Redirect if not logged in ──────────────────────────────────────────
|
||||
// ── 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],
|
||||
}),
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────
|
||||
// ── Fetch places ──────────────────────────────────────────
|
||||
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -96,7 +211,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [search, typeFilter]);
|
||||
|
||||
// ── Fetch usage ────────────────────────────────────────────────────────
|
||||
// ── Fetch usage ───────────────────────────────────────────
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
@@ -112,7 +227,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Fetch bookmarks ────────────────────────────────────────────────────
|
||||
// ── Fetch bookmarks ───────────────────────────────────────
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
@@ -128,7 +243,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Track usage query ──────────────────────────────────────────────────
|
||||
// ── Track usage query ─────────────────────────────────────
|
||||
const trackQuery = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setUsageLoading(true);
|
||||
@@ -142,15 +257,9 @@ export default function HalalMonitorPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsage((prev) =>
|
||||
prev
|
||||
? { ...prev, ...data }
|
||||
: data
|
||||
);
|
||||
setUsage((prev) => (prev ? { ...prev, ...data } : data));
|
||||
} else if (res.status === 429) {
|
||||
// Rate limited — update usage from error response
|
||||
const data = await res.json();
|
||||
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
|
||||
setUsage((prev) => (prev ? { ...prev, remaining: 0 } : prev));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -159,10 +268,9 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Initial load ───────────────────────────────────────────────────────
|
||||
// ── Initial load ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (loading || !token) return;
|
||||
|
||||
const init = async () => {
|
||||
setPageLoading(true);
|
||||
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
|
||||
@@ -171,7 +279,33 @@ export default function HalalMonitorPage() {
|
||||
init();
|
||||
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
|
||||
|
||||
// ── Search / filter triggers ───────────────────────────────────────────
|
||||
// ── Compute distances & sort by distance ──────────────────
|
||||
const sortedPlaces = useMemo(() => {
|
||||
if (!places.length) return [];
|
||||
const withDistances = places.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, 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();
|
||||
@@ -186,16 +320,19 @@ export default function HalalMonitorPage() {
|
||||
[search, fetchPlaces, isFree, trackQuery]
|
||||
);
|
||||
|
||||
// ── Bookmark toggle ────────────────────────────────────────────────────
|
||||
// ── 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}` },
|
||||
});
|
||||
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", {
|
||||
@@ -221,12 +358,7 @@ export default function HalalMonitorPage() {
|
||||
[bookmarks]
|
||||
);
|
||||
|
||||
// ── Filter visible places (premium gating) ─────────────────────────────
|
||||
const visiblePlaces = isFree
|
||||
? places.filter((p) => p.type === "mosque")
|
||||
: places;
|
||||
|
||||
// ── Render stars ───────────────────────────────────────────────────────
|
||||
// ── Render stars ──────────────────────────────────────────
|
||||
const renderStars = (rating: number) => {
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
@@ -234,7 +366,11 @@ export default function HalalMonitorPage() {
|
||||
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" />
|
||||
<Star
|
||||
key={`full-${i}`}
|
||||
size={12}
|
||||
className="fill-amber-400 text-amber-400"
|
||||
/>
|
||||
))}
|
||||
{half && (
|
||||
<div className="relative">
|
||||
@@ -249,12 +385,19 @@ export default function HalalMonitorPage() {
|
||||
{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 className="text-xs text-gray-400 ml-1">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Premium Gating Overlay ─────────────────────────────────────────────
|
||||
// ── 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">
|
||||
@@ -267,7 +410,7 @@ export default function HalalMonitorPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
{/* ── 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">
|
||||
@@ -286,7 +429,12 @@ export default function HalalMonitorPage() {
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
|
||||
width: `${Math.min(
|
||||
100,
|
||||
((usage?.queriesUsed ?? 0) /
|
||||
(usage?.queriesLimit ?? 10)) *
|
||||
100
|
||||
)}%`,
|
||||
backgroundColor:
|
||||
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
||||
}}
|
||||
@@ -299,7 +447,7 @@ export default function HalalMonitorPage() {
|
||||
Discover halal-certified places around you
|
||||
</p>
|
||||
|
||||
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
|
||||
{/* ── Premium Upgrade Banner ─────────────────────── */}
|
||||
{isFree && (
|
||||
<button
|
||||
onClick={() => router.push("/upgrade")}
|
||||
@@ -314,7 +462,7 @@ export default function HalalMonitorPage() {
|
||||
Upgrade for Unlimited
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Unlock restaurants + cafes & unlimited queries
|
||||
Unlock unlimited queries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,12 +474,12 @@ export default function HalalMonitorPage() {
|
||||
{isFree && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
|
||||
<Crown size={12} />
|
||||
Free tier: mosques only • 10 queries/day
|
||||
Free tier: 10 queries/day
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search Bar ─────────────────────────────────────────────────── */}
|
||||
{/* ── 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" />
|
||||
@@ -344,19 +492,25 @@ export default function HalalMonitorPage() {
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
fetchPlaces("", typeFilter);
|
||||
}}
|
||||
>
|
||||
<X size={14} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category Filter ────────────────────────────────────────────── */}
|
||||
{/* ── 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;
|
||||
const isDisabled = isFree && t !== "all" && t !== "mosque";
|
||||
// All filter buttons enabled for free tier (rate limiting stays)
|
||||
const isDisabled = false;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
@@ -377,51 +531,58 @@ export default function HalalMonitorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map Placeholder ────────────────────────────────────────────── */}
|
||||
{/* ── Leaflet Map ──────────────────────────────────── */}
|
||||
<div className="mx-4 mb-4">
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full h-48 rounded-2xl overflow-hidden relative bg-gradient-to-br from-emerald-900/40 via-[#111118] to-gray-900 border border-gray-800/60"
|
||||
>
|
||||
{/* Decorative grid lines */}
|
||||
<div className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
{/* Center pin */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center animate-pulse">
|
||||
<MapPin size={18} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-xs text-emerald-500/60 font-medium">
|
||||
{visiblePlaces.length} places found
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map labels */}
|
||||
{visiblePlaces.slice(0, 5).map((place) => (
|
||||
<div
|
||||
key={place.id}
|
||||
className="absolute w-2 h-2 rounded-full bg-emerald-400 shadow-lg shadow-emerald-500/40 animate-pulse"
|
||||
style={{
|
||||
left: `${((place.lng - 101.6) / 0.2) * 50 + 25}%`,
|
||||
top: `${((3.2 - place.lat) / 0.3) * 50 + 15}%`,
|
||||
}}
|
||||
<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}
|
||||
>
|
||||
<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={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 ───────────────────────────────────────────────── */}
|
||||
{/* ── 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"}
|
||||
{visiblePlaces.length}{" "}
|
||||
{visiblePlaces.length === 1 ? "place" : "places"}
|
||||
{userLocation && " · Nearest first"}
|
||||
</h2>
|
||||
{usageLoading && (
|
||||
<Loader2 size={12} className="animate-spin text-gray-600" />
|
||||
@@ -437,9 +598,7 @@ export default function HalalMonitorPage() {
|
||||
<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-xs text-gray-700 mt-1">
|
||||
{isFree
|
||||
? "Free tier shows mosques only. Try a different search."
|
||||
: "Try adjusting your search or filters."}
|
||||
Try adjusting your search or filters.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -466,7 +625,11 @@ export default function HalalMonitorPage() {
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{place.type === "mosque" ? "Mosque" : "Restaurant"}
|
||||
{place.type === "mosque"
|
||||
? "Mosque"
|
||||
: place.type === "restaurant"
|
||||
? "Restaurant"
|
||||
: "Cafe"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -478,13 +641,22 @@ export default function HalalMonitorPage() {
|
||||
{/* Rating */}
|
||||
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
||||
|
||||
{/* Halal badge */}
|
||||
{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>
|
||||
)}
|
||||
{/* 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 */}
|
||||
@@ -512,7 +684,7 @@ export default function HalalMonitorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detail Modal ────────────────────────────────────────────────── */}
|
||||
{/* ── Detail Modal ──────────────────────────────────── */}
|
||||
{selectedPlace && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
|
||||
@@ -547,7 +719,11 @@ export default function HalalMonitorPage() {
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"}
|
||||
{selectedPlace.type === "mosque"
|
||||
? "🕌 Mosque"
|
||||
: selectedPlace.type === "restaurant"
|
||||
? "🍽️ Restaurant"
|
||||
: "☕ Cafe"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -569,7 +745,9 @@ export default function HalalMonitorPage() {
|
||||
{/* 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>
|
||||
<p className="text-sm text-gray-400">
|
||||
{selectedPlace.address}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
@@ -581,6 +759,19 @@ export default function HalalMonitorPage() {
|
||||
<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">
|
||||
@@ -597,6 +788,17 @@ export default function HalalMonitorPage() {
|
||||
</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={() => {
|
||||
|
||||
Reference in New Issue
Block a user