Add Prayer Times, Dhikr counter, Quran tracker with mobile UI overhaul
- Prayer times page: arc progress ring, next-prayer countdown, clean prayer list - Dhikr counter: SVG ring with glow, 3-phase sequence (SubhanAllah/Alhamdulillah/AllahuAkbar), streak tracking - Daily Quran reading tracker API routes (GET/POST) - Bottom nav updated: Prayer + Dhikr tabs, Repeat2 icon for dhikr, Moon for prayer - Home page redirects to /prayer as primary entry point - Dockerfile: openssl added to builder stage, explicit prisma schema path fixes build - Schema: dhikrStreak, quranStreak on User; DhikrSession and QuranReading models - Stub wallet top-up checkout to fix TypeScript build error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { verifyJWT } from '@/lib/auth'
|
||||
|
||||
const FREE_DAILY_LIMIT = 20
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface OverpassElement {
|
||||
id: number
|
||||
lat?: number
|
||||
lon?: number
|
||||
center?: { lat: number; lon: number }
|
||||
tags?: Record<string, string>
|
||||
}
|
||||
|
||||
interface Place {
|
||||
id: string
|
||||
lat: number
|
||||
lon: number
|
||||
name: string
|
||||
type: 'mosque' | 'restaurant'
|
||||
address?: string
|
||||
}
|
||||
|
||||
const OVERPASS_ENDPOINTS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
]
|
||||
|
||||
async function queryOverpass(query: string): Promise<OverpassElement[]> {
|
||||
let lastError: Error | null = null
|
||||
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) { lastError = new Error(`Overpass error ${res.status} from ${endpoint}`); continue }
|
||||
const data = await res.json()
|
||||
return data.elements || []
|
||||
} catch (e) {
|
||||
lastError = e as Error
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('All Overpass endpoints failed')
|
||||
}
|
||||
|
||||
function buildMosqueQuery(lat: number, lon: number, radius: number): string {
|
||||
return `[out:json][timeout:10];(node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon});way["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon}););out center;`
|
||||
}
|
||||
|
||||
function buildRestaurantQuery(lat: number, lon: number, radius: number): string {
|
||||
return `[out:json][timeout:10];(node["diet:halal"="yes"](around:${radius},${lat},${lon});node["cuisine"~"halal"](around:${radius},${lat},${lon});way["diet:halal"="yes"](around:${radius},${lat},${lon}););out center;`
|
||||
}
|
||||
|
||||
function elementsToPlaces(elements: OverpassElement[], type: 'mosque' | 'restaurant'): Place[] {
|
||||
return elements
|
||||
.filter(e => {
|
||||
const lat = e.lat ?? e.center?.lat
|
||||
const lon = e.lon ?? e.center?.lon
|
||||
return lat !== undefined && lon !== undefined
|
||||
})
|
||||
.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: (e.lat ?? e.center?.lat)!,
|
||||
lon: (e.lon ?? e.center?.lon)!,
|
||||
name: e.tags?.name || e.tags?.['name:en'] || (type === 'mosque' ? 'Mosque' : 'Halal Restaurant'),
|
||||
type,
|
||||
address: [e.tags?.['addr:street'], e.tags?.['addr:city']].filter(Boolean).join(', ') || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getUserFromRequest(req: NextRequest) {
|
||||
const auth = req.headers.get('authorization')
|
||||
if (!auth?.startsWith('Bearer ')) return null
|
||||
const payload = await verifyJWT(auth.slice(7))
|
||||
if (!payload) return null
|
||||
return prisma.user.findUnique({ where: { id: payload.id } })
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const lat = parseFloat(searchParams.get('lat') || '')
|
||||
const lon = parseFloat(searchParams.get('lng') || '')
|
||||
const radius = Math.min(parseInt(searchParams.get('radius') || '5000'), 10000)
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
return NextResponse.json({ error: 'lat and lng required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await getUserFromRequest(req)
|
||||
const isPremium = user?.isPremium || user?.isPro || false
|
||||
|
||||
// Rate limit free users
|
||||
if (user && !isPremium) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
let usage = await prisma.halalUsage.findUnique({ where: { userId: user.id } })
|
||||
if (!usage || !usage.periodEnd || usage.periodEnd < today) {
|
||||
usage = await prisma.halalUsage.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
|
||||
update: { queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
|
||||
})
|
||||
}
|
||||
if (usage.queriesUsed >= FREE_DAILY_LIMIT) {
|
||||
return NextResponse.json({ error: 'daily_limit_reached', limit: FREE_DAILY_LIMIT }, { status: 429 })
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `${isPremium ? 'premium' : 'free'}:${lat.toFixed(2)}:${lon.toFixed(2)}:${radius}`
|
||||
const cached = await prisma.halalPlaceCache.findUnique({ where: { cacheKey } })
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return NextResponse.json(JSON.parse(cached.data))
|
||||
}
|
||||
|
||||
// Fetch from Overpass
|
||||
const mosqueElements = await queryOverpass(buildMosqueQuery(lat, lon, radius))
|
||||
const mosques = elementsToPlaces(mosqueElements, 'mosque')
|
||||
|
||||
let restaurants: Place[] = []
|
||||
if (isPremium) {
|
||||
const restaurantElements = await queryOverpass(buildRestaurantQuery(lat, lon, radius))
|
||||
restaurants = elementsToPlaces(restaurantElements, 'restaurant')
|
||||
}
|
||||
|
||||
const result = { mosques, restaurants }
|
||||
|
||||
// Cache result
|
||||
await prisma.halalPlaceCache.upsert({
|
||||
where: { cacheKey },
|
||||
create: { cacheKey, data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
|
||||
update: { data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
|
||||
})
|
||||
|
||||
// Increment usage for free users
|
||||
if (user && !isPremium) {
|
||||
await prisma.halalUsage.update({
|
||||
where: { userId: user.id },
|
||||
data: { queriesUsed: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (e) {
|
||||
console.error('Halal places error:', e)
|
||||
return NextResponse.json({ error: 'Failed to fetch places' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user