354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
|
|
import { useAuth } from '@/lib/AuthContext'
|
||
|
|
import { Crown, Lock, Search, MapPin, Navigation, Bookmark, X, Loader2 } from 'lucide-react'
|
||
|
|
import dynamic from 'next/dynamic'
|
||
|
|
|
||
|
|
// Leaflet must be loaded client-side only (no SSR)
|
||
|
|
const MapContainer = 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 })
|
||
|
|
|
||
|
|
interface Place {
|
||
|
|
id: string
|
||
|
|
lat: number
|
||
|
|
lon: number
|
||
|
|
name: string
|
||
|
|
type: 'mosque' | 'restaurant'
|
||
|
|
address?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface MapCenter {
|
||
|
|
lat: number
|
||
|
|
lon: number
|
||
|
|
}
|
||
|
|
|
||
|
|
function UpgradeCTA() {
|
||
|
|
return (
|
||
|
|
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
|
||
|
|
<Crown size={48} className="text-[#D4AF37]" />
|
||
|
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
|
||
|
|
<p className="text-gray-400 max-w-md">
|
||
|
|
Find mosques and halal restaurants anywhere in the world. Available to Premium members.
|
||
|
|
</p>
|
||
|
|
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-sm w-full space-y-3">
|
||
|
|
<Crown size={32} className="text-[#D4AF37] mx-auto" />
|
||
|
|
<h2 className="font-bold">Upgrade to Premium</h2>
|
||
|
|
<ul className="text-sm text-gray-400 space-y-1 text-left">
|
||
|
|
<li>• Mosques and prayer spaces worldwide</li>
|
||
|
|
<li>• Halal restaurants near you</li>
|
||
|
|
<li>• Bookmark favourite places</li>
|
||
|
|
<li>• City search anywhere in the world</li>
|
||
|
|
</ul>
|
||
|
|
<a
|
||
|
|
href="/upgrade"
|
||
|
|
className="block w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition text-center"
|
||
|
|
>
|
||
|
|
Upgrade Now
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function LoginGate() {
|
||
|
|
return (
|
||
|
|
<div className="max-w-6xl mx-auto p-8 flex flex-col items-center justify-center min-h-[60vh] text-center space-y-4">
|
||
|
|
<Lock size={48} className="text-gray-600" />
|
||
|
|
<h1 className="text-2xl font-bold text-[#D4AF37]">Halal Monitor</h1>
|
||
|
|
<p className="text-gray-400 max-w-md">
|
||
|
|
Sign in to access the global halal map — mosques, prayer spaces, and halal restaurants near you.
|
||
|
|
</p>
|
||
|
|
<a
|
||
|
|
href="/login"
|
||
|
|
className="inline-flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-6 py-2 rounded font-semibold hover:bg-[#C9A84C] transition"
|
||
|
|
>
|
||
|
|
Sign In
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function HalalMonitorPage() {
|
||
|
|
const { token, user, loading: authLoading } = useAuth()
|
||
|
|
const [places, setPlaces] = useState<Place[]>([])
|
||
|
|
const [center, setCenter] = useState<MapCenter | null>(null)
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
const [error, setError] = useState<string | null>(null)
|
||
|
|
const [citySearch, setCitySearch] = useState('')
|
||
|
|
const [bookmarked, setBookmarked] = useState<Set<string>>(new Set())
|
||
|
|
const [leafletLoaded, setLeafletLoaded] = useState(false)
|
||
|
|
const mapRef = useRef<any>(null)
|
||
|
|
|
||
|
|
// Load Leaflet CSS
|
||
|
|
useEffect(() => {
|
||
|
|
if (typeof window === 'undefined') return
|
||
|
|
const link = document.createElement('link')
|
||
|
|
link.rel = 'stylesheet'
|
||
|
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
||
|
|
document.head.appendChild(link)
|
||
|
|
setLeafletLoaded(true)
|
||
|
|
return () => { document.head.removeChild(link) }
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
// Load existing bookmarks
|
||
|
|
useEffect(() => {
|
||
|
|
if (!token) return
|
||
|
|
fetch('/api/halal/bookmarks', { headers: { 'Authorization': `Bearer ${token}` } })
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(d => {
|
||
|
|
if (d.bookmarks) setBookmarked(new Set(d.bookmarks.map((b: any) => b.itemId)))
|
||
|
|
})
|
||
|
|
.catch(() => {})
|
||
|
|
}, [token])
|
||
|
|
|
||
|
|
const fetchPlaces = useCallback(async (lat: number, lon: number) => {
|
||
|
|
setLoading(true)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/halal/places?lat=${lat}&lng=${lon}&radius=5000`, {
|
||
|
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||
|
|
})
|
||
|
|
const data = await res.json()
|
||
|
|
if (res.status === 429) {
|
||
|
|
setError('Daily search limit reached. Upgrade to Premium for unlimited searches.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!res.ok) throw new Error(data.error || 'Failed to load places')
|
||
|
|
setPlaces([...data.mosques, ...(data.restaurants || [])])
|
||
|
|
} catch (e: any) {
|
||
|
|
setError(e.message || 'Failed to load places')
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}, [token])
|
||
|
|
|
||
|
|
const handleGeolocate = () => {
|
||
|
|
if (!navigator.geolocation) {
|
||
|
|
setError('Geolocation is not supported by your browser.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setLoading(true)
|
||
|
|
navigator.geolocation.getCurrentPosition(
|
||
|
|
pos => {
|
||
|
|
const { latitude: lat, longitude: lon } = pos.coords
|
||
|
|
setCenter({ lat, lon })
|
||
|
|
fetchPlaces(lat, lon)
|
||
|
|
},
|
||
|
|
() => {
|
||
|
|
setLoading(false)
|
||
|
|
setError('Could not get your location. Try searching by city.')
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleCitySearch = async (e: React.FormEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!citySearch.trim()) return
|
||
|
|
setLoading(true)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
const res = await fetch(
|
||
|
|
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(citySearch)}&format=json&limit=1`,
|
||
|
|
{ headers: { 'User-Agent': 'FalahMobile/1.0' } }
|
||
|
|
)
|
||
|
|
const results = await res.json()
|
||
|
|
if (!results.length) {
|
||
|
|
setError('City not found. Try a different search term.')
|
||
|
|
setLoading(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const lat = parseFloat(results[0].lat)
|
||
|
|
const lon = parseFloat(results[0].lon)
|
||
|
|
setCenter({ lat, lon })
|
||
|
|
fetchPlaces(lat, lon)
|
||
|
|
} catch {
|
||
|
|
setError('City search failed. Please try again.')
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleBookmark = async (place: Place) => {
|
||
|
|
if (!token) return
|
||
|
|
const isBookmarked = bookmarked.has(place.id)
|
||
|
|
if (isBookmarked) {
|
||
|
|
const res = await fetch(`/api/halal/bookmarks?itemId=${place.id}`, {
|
||
|
|
method: 'DELETE',
|
||
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
||
|
|
})
|
||
|
|
if (res.ok) setBookmarked(prev => { const next = new Set(prev); next.delete(place.id); return next })
|
||
|
|
} else {
|
||
|
|
const res = await fetch('/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) setBookmarked(prev => new Set([...prev, place.id]))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (authLoading) {
|
||
|
|
return <div className="p-8 text-center text-gray-500 animate-pulse">Loading...</div>
|
||
|
|
}
|
||
|
|
if (!token || !user) return <LoginGate />
|
||
|
|
if (!user.isPremium && !user.isPro) return <UpgradeCTA />
|
||
|
|
|
||
|
|
const mosques = places.filter(p => p.type === 'mosque')
|
||
|
|
const restaurants = places.filter(p => p.type === 'restaurant')
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||
|
|
{/* Toolbar */}
|
||
|
|
<div className="bg-[#111118] border-b border-gray-800 px-4 py-2 flex items-center gap-3 shrink-0 flex-wrap">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Crown size={16} className="text-[#D4AF37]" />
|
||
|
|
<span className="text-sm font-semibold text-[#D4AF37]">Halal Monitor</span>
|
||
|
|
</div>
|
||
|
|
<form onSubmit={handleCitySearch} className="flex gap-2 flex-1 max-w-sm">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="Search city..."
|
||
|
|
value={citySearch}
|
||
|
|
onChange={e => setCitySearch(e.target.value)}
|
||
|
|
className="w-full bg-[#0a0a0f] border border-gray-700 rounded pl-8 pr-3 py-1.5 text-xs focus:border-[#D4AF37] outline-none"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<button type="submit" className="bg-[#D4AF37] text-[#0a0a0f] px-3 py-1.5 rounded text-xs font-semibold hover:bg-[#C9A84C] transition">
|
||
|
|
Go
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
<button
|
||
|
|
onClick={handleGeolocate}
|
||
|
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-[#D4AF37] transition"
|
||
|
|
>
|
||
|
|
<Navigation size={14} /> Near me
|
||
|
|
</button>
|
||
|
|
{loading && <Loader2 size={14} className="animate-spin text-[#D4AF37]" />}
|
||
|
|
{places.length > 0 && (
|
||
|
|
<span className="text-xs text-gray-500 ml-auto">
|
||
|
|
{mosques.length} mosques · {restaurants.length} restaurants
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Error bar */}
|
||
|
|
{error && (
|
||
|
|
<div className="bg-red-900/20 border-b border-red-800 px-4 py-2 flex items-center justify-between shrink-0">
|
||
|
|
<span className="text-xs text-red-400">{error}</span>
|
||
|
|
<button onClick={() => setError(null)}><X size={14} className="text-red-400" /></button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Map */}
|
||
|
|
<div className="flex-1 relative">
|
||
|
|
{!center ? (
|
||
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center space-y-4">
|
||
|
|
<MapPin size={40} className="text-gray-700" />
|
||
|
|
<p className="text-gray-500 text-sm">Search a city or use your location to find halal places.</p>
|
||
|
|
<button
|
||
|
|
onClick={handleGeolocate}
|
||
|
|
className="flex items-center gap-2 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"
|
||
|
|
>
|
||
|
|
<Navigation size={16} /> Use My Location
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
) : leafletLoaded ? (
|
||
|
|
<MapWithMarkers
|
||
|
|
center={center}
|
||
|
|
places={places}
|
||
|
|
bookmarked={bookmarked}
|
||
|
|
onBookmark={handleBookmark}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function MapWithMarkers({
|
||
|
|
center,
|
||
|
|
places,
|
||
|
|
bookmarked,
|
||
|
|
onBookmark,
|
||
|
|
}: {
|
||
|
|
center: MapCenter
|
||
|
|
places: Place[]
|
||
|
|
bookmarked: Set<string>
|
||
|
|
onBookmark: (p: Place) => void
|
||
|
|
}) {
|
||
|
|
const [L, setL] = useState<any>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
import('leaflet').then(leaflet => {
|
||
|
|
// Fix default marker icons for Next.js
|
||
|
|
delete (leaflet.Icon.Default.prototype as any)._getIconUrl
|
||
|
|
leaflet.Icon.Default.mergeOptions({
|
||
|
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||
|
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||
|
|
})
|
||
|
|
setL(leaflet)
|
||
|
|
})
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
if (!L) return null
|
||
|
|
|
||
|
|
const mosqueIcon = new L.Icon({
|
||
|
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png',
|
||
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||
|
|
iconSize: [25, 41],
|
||
|
|
iconAnchor: [12, 41],
|
||
|
|
popupAnchor: [1, -34],
|
||
|
|
shadowSize: [41, 41],
|
||
|
|
})
|
||
|
|
|
||
|
|
const restaurantIcon = new L.Icon({
|
||
|
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png',
|
||
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||
|
|
iconSize: [25, 41],
|
||
|
|
iconAnchor: [12, 41],
|
||
|
|
popupAnchor: [1, -34],
|
||
|
|
shadowSize: [41, 41],
|
||
|
|
})
|
||
|
|
|
||
|
|
return (
|
||
|
|
<MapContainer
|
||
|
|
center={[center.lat, center.lon]}
|
||
|
|
zoom={14}
|
||
|
|
style={{ height: '100%', width: '100%' }}
|
||
|
|
key={`${center.lat}-${center.lon}`}
|
||
|
|
>
|
||
|
|
<TileLayer
|
||
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
|
|
/>
|
||
|
|
{places.map(place => (
|
||
|
|
<Marker
|
||
|
|
key={place.id}
|
||
|
|
position={[place.lat, place.lon]}
|
||
|
|
icon={place.type === 'mosque' ? mosqueIcon : restaurantIcon}
|
||
|
|
>
|
||
|
|
<Popup>
|
||
|
|
<div className="min-w-[160px]">
|
||
|
|
<p className="font-semibold text-sm">{place.name}</p>
|
||
|
|
{place.address && <p className="text-xs text-gray-500 mt-0.5">{place.address}</p>}
|
||
|
|
<p className="text-xs text-gray-400 mt-1 capitalize">{place.type}</p>
|
||
|
|
<button
|
||
|
|
onClick={() => onBookmark(place)}
|
||
|
|
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||
|
|
>
|
||
|
|
<Bookmark size={12} />
|
||
|
|
{bookmarked.has(place.id) ? 'Bookmarked' : 'Bookmark'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</Popup>
|
||
|
|
</Marker>
|
||
|
|
))}
|
||
|
|
</MapContainer>
|
||
|
|
)
|
||
|
|
}
|