Files
falah-mobile/src/app/halal-monitor/page.tsx
T

629 lines
25 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
MapPin,
Search,
Heart,
X,
Star,
ChevronRight,
Sparkles,
Crown,
Loader2,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────
interface Place {
id: string;
name: string;
address: string;
lat: number;
lng: number;
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
}
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;
}
// ─── Constants ────────────────────────────────────────────────────────────
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
const TYPE_LABELS: Record<string, string> = {
all: "All",
mosque: "Mosques",
restaurant: "Restaurants",
cafe: "Cafes",
};
// ─── 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 [mapReady, setMapReady] = useState(false);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const isPremium = user?.isPremium || user?.isPro || false;
const isFree = !isPremium;
// ── 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);
const res = await fetch(`/mobile/api/halal/places?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPlaces(data.places);
}
} catch {
// ignore
}
}, [search, typeFilter]);
// ── 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]);
// ── 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) {
// Rate limited — update usage from error response
const data = await res.json();
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()]);
setPageLoading(false);
};
init();
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
// ── 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]
);
// ── Filter visible places (premium gating) ─────────────────────────────
const visiblePlaces = isFree
? places.filter((p) => p.type === "mosque")
: places;
// ── 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>
);
};
// ── 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>
{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>
<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 restaurants + cafes & 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: mosques only &bull; 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;
const isDisabled = isFree && t !== "all" && t !== "mosque";
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>
{/* ── Map Placeholder ────────────────────────────────────────────── */}
<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>
</div>
{/* ── 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"}
</h2>
{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 ? (
<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-xs text-gray-700 mt-1">
{isFree
? "Free tier shows mosques only. Try a different search."
: "Try adjusting your search or filters."}
</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" : "Restaurant"}
</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>
{/* 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>
)}
</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" : "🍽️ Restaurant"}
</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>
{/* 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>
{/* 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>
</div>
</div>
</div>
)}
</div>
);
}