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:
root
2026-06-18 14:51:07 +02:00
parent ed34b186f9
commit 28be776c84
13 changed files with 2822 additions and 104 deletions
+300 -98
View File
@@ -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 &bull; 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='&copy; <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={() => {