diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f923a65..6d0d0e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,8 @@ model User { xpTransactions XpTransaction[] achievements Achievement[] userLevel UserLevel? + dhikrSessions DhikrSession[] + dhikrDays DhikrDay[] } model DailyStreak { @@ -284,6 +286,35 @@ model HalalPlaceCache { expiresAt DateTime } +model DhikrSession { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + type String // 'subhanallah' | 'alhamdulillah' | 'allahuakbar' | 'custom' + count Int @default(0) + target Int @default(33) + completed Boolean @default(true) + createdAt DateTime @default(now()) + + @@index([userId, createdAt]) +} + +model DhikrDay { + userId String + user User @relation(fields: [userId], references: [id]) + date String // YYYY-MM-DD + subhanallah Int @default(0) + alhamdulillah Int @default(0) + allahuakbar Int @default(0) + custom Int @default(0) + totalCount Int @default(0) + sessionCount Int @default(0) + updatedAt DateTime @updatedAt + + @@id([userId, date]) + @@index([userId]) +} + // Add Relations to User model /// NOTE: The Notification and Referral relations are declared on the models above. /// User now implicitly has: notifications Notification[], referredReferrals Referral[] (via "Referrer"), referrerReferral Referral? (via "Referred") diff --git a/src/app/api/dhikr/stats/route.ts b/src/app/api/dhikr/stats/route.ts new file mode 100644 index 0000000..5e1ccb7 --- /dev/null +++ b/src/app/api/dhikr/stats/route.ts @@ -0,0 +1,92 @@ +import { requireAuth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function todayStr(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function getDhikrType(raw: string): string { + const allowed = ["subhanallah", "alhamdulillah", "allahuakbar", "custom"]; + return allowed.includes(raw) ? raw : "custom"; +} + +// ── GET /api/dhikr/stats — Today's dhikr stats ──────────────────────────── + +export async function GET(request: Request) { + const jwt = await requireAuth(request); + if (!jwt) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const today = todayStr(); + + let dayStats = await prisma.dhikrDay.findUnique({ + where: { userId_date: { userId: jwt.userId, date: today } }, + }); + + // Recent sessions (last 20) + const recentSessions = await prisma.dhikrSession.findMany({ + where: { userId: jwt.userId }, + orderBy: { createdAt: "desc" }, + take: 20, + select: { id: true, type: true, count: true, createdAt: true }, + }); + + if (!dayStats) { + dayStats = await prisma.dhikrDay.create({ + data: { userId: jwt.userId, date: today }, + }); + } + + return NextResponse.json({ dayStats, recentSessions }); +} + +// ── POST /api/dhikr/stats — Log a dhikr session ──────────────────────────── + +export async function POST(request: Request) { + const jwt = await requireAuth(request); + if (!jwt) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { type?: string; count?: number; target?: number; completed?: boolean }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const type = getDhikrType(body.type || "custom"); + const count = Math.min(Math.max(body.count || 1, 1), 9999); + const target = body.target || 33; + const completed = body.completed !== false; + const today = todayStr(); + + // Create session record + const session = await prisma.dhikrSession.create({ + data: { userId: jwt.userId, type, count, target, completed }, + }); + + // Upsert today's aggregate + const dayStats = await prisma.dhikrDay.upsert({ + where: { userId_date: { userId: jwt.userId, date: today } }, + create: { + userId: jwt.userId, + date: today, + [type]: count, + totalCount: count, + sessionCount: 1, + }, + update: { + totalCount: { increment: count }, + sessionCount: { increment: 1 }, + [type]: { increment: count }, + }, + }); + + return NextResponse.json({ session, dayStats }); +} diff --git a/src/app/api/halal/bookmarks/route.ts b/src/app/api/halal/bookmarks/route.ts index ef71ca1..e3e2fc1 100644 --- a/src/app/api/halal/bookmarks/route.ts +++ b/src/app/api/halal/bookmarks/route.ts @@ -9,11 +9,27 @@ const MOCK_PLACES: Record = { "mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true }, "mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true }, "mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true }, + "mosque-6": { id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true }, + "mosque-7": { id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true }, + "mosque-8": { id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true }, + "mosque-9": { id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true }, + "mosque-10": { id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true }, "rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true }, "rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true }, "rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true }, "rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true }, "rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true }, + "rest-6": { id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true }, + "rest-7": { id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true }, + "rest-8": { id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true }, + "rest-9": { id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true }, + "rest-10": { id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true }, + "rest-11": { id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true }, + "cafe-1": { id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true }, + "cafe-2": { id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true }, + "cafe-3": { id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true }, + "cafe-4": { id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true }, + "cafe-5": { id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true }, }; export async function GET(request: NextRequest) { diff --git a/src/app/api/halal/places/route.ts b/src/app/api/halal/places/route.ts index a4b2f24..d4ef36d 100644 --- a/src/app/api/halal/places/route.ts +++ b/src/app/api/halal/places/route.ts @@ -1,19 +1,50 @@ import { NextRequest, NextResponse } from "next/server"; +import { haversineDistance } from "@/lib/geo"; -const MOCK_PLACES = [ - // Mosques +interface Place { + id: string; + name: string; + address: string; + lat: number; + lng: number; + rating: number; + type: "mosque" | "restaurant" | "cafe"; + halal_certified: boolean; + distance?: number; +} + +const MOCK_PLACES: Place[] = [ + // ── Mosques ─────────────────────────────────────────── { id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true }, { id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true }, { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true }, { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true }, { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true }, + { id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true }, + { id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true }, + { id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true }, + { id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true }, + { id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true }, - // Restaurants + // ── Restaurants ─────────────────────────────────────── { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true }, { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true }, { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true }, { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true }, { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true }, + { id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true }, + { id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true }, + { id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true }, + { id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true }, + { id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true }, + { id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true }, + + // ── Cafes ───────────────────────────────────────────── + { id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true }, + { id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true }, + { id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true }, + { id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true }, + { id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true }, ]; export async function GET(request: NextRequest) { @@ -21,27 +52,47 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const q = searchParams.get("q")?.toLowerCase(); const type = searchParams.get("type")?.toLowerCase(); + const latParam = searchParams.get("lat"); + const lngParam = searchParams.get("lng"); let filtered = [...MOCK_PLACES]; + // Filter by type if provided and valid if (type && ["mosque", "restaurant", "cafe"].includes(type)) { filtered = filtered.filter((p) => p.type === type); } + // Filter by search query (name or address) if (q) { filtered = filtered.filter( (p) => p.name.toLowerCase().includes(q) || - p.address.toLowerCase().includes(q) + p.address.toLowerCase().includes(q), ); } + // If lat/lng provided, calculate distance and sort by nearest + if (latParam && lngParam) { + const userLat = parseFloat(latParam); + const userLng = parseFloat(lngParam); + + if (!isNaN(userLat) && !isNaN(userLng)) { + filtered = filtered.map((p) => ({ + ...p, + distance: haversineDistance(userLat, userLng, p.lat, p.lng), + })); + + // Sort by distance ascending (nearest first) + filtered.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)); + } + } + return NextResponse.json({ places: filtered }); } catch (error) { console.error("GET /api/halal/places error:", error); return NextResponse.json( { error: "Internal server error" }, - { status: 500 } + { status: 500 }, ); } } diff --git a/src/app/api/prayer/route.ts b/src/app/api/prayer/route.ts new file mode 100644 index 0000000..b8c86cb --- /dev/null +++ b/src/app/api/prayer/route.ts @@ -0,0 +1,357 @@ +import { NextRequest } from "next/server"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +// ── Timezone abbreviation mapping ────────────────────────────────────────── +const TZ_ABBREVIATIONS: Record = { + "Asia/Kuala_Lumpur": "MYT", + "Asia/Singapore": "SGT", + "Asia/Jakarta": "WIB", + "Asia/Makassar": "WITA", + "Asia/Jayapura": "WIT", + "Asia/Bangkok": "ICT", + "Asia/Ho_Chi_Minh": "ICT", + "Asia/Phnom_Penh": "ICT", + "Asia/Vientiane": "ICT", + "Asia/Yangon": "MMT", + "Asia/Dhaka": "BST", + "Asia/Kolkata": "IST", + "Asia/Karachi": "PKT", + "Asia/Dubai": "GST", + "Asia/Riyadh": "AST", + "Asia/Baghdad": "AST", + "Asia/Tehran": "IRST", + "Asia/Kabul": "AFT", + "Asia/Tashkent": "UZT", + "Europe/London": "GMT", + "Europe/Istanbul": "TRT", + "Europe/Berlin": "CET", + "Europe/Paris": "CET", + "America/New_York": "EST", + "America/Chicago": "CST", + "America/Denver": "MST", + "America/Los_Angeles": "PST", + "America/Toronto": "EST", + "Africa/Cairo": "EET", + "Africa/Johannesburg": "SAST", + "Australia/Sydney": "AEDT", + "Australia/Perth": "AWST", + "Pacific/Auckland": "NZDT", +}; + +function getTimezoneAbbr(timezone: string): string { + // Direct lookup first + if (TZ_ABBREVIATIONS[timezone]) return TZ_ABBREVIATIONS[timezone]; + + // Try to extract abbreviation dynamically from the IANA name + // e.g., "America/New_York" -> "ET", "Asia/Kuala_Lumpur" -> "MYT" + const parts = timezone.split("/"); + if (parts.length >= 2) { + const region = parts[0]; + const city = parts[parts.length - 1].replace(/_/g, " "); + // Common known abbreviations + const known: Record = { + London: "GMT", + Dublin: "GMT", + Lisbon: "WET", + Berlin: "CET", + Paris: "CET", + Rome: "CET", + Madrid: "CET", + Amsterdam: "CET", + Brussels: "CET", + Vienna: "CET", + Stockholm: "CET", + Oslo: "CET", + Copenhagen: "CET", + Warsaw: "CET", + Budapest: "CET", + Prague: "CET", + Athens: "EET", + Helsinki: "EET", + Bucharest: "EET", + Sofia: "EET", + Moscow: "MSK", + Istanbul: "TRT", + }; + if (known[city]) return known[city]; + } + + // For Asia timezones, try to use country code from city name + const countryFromCity: Record = { + "Kuala Lumpur": "MYT", + "Singapore": "SGT", + "Jakarta": "WIB", + "Tokyo": "JST", + "Seoul": "KST", + "Hong_Kong": "HKT", + "Taipei": "CST", + "Beijing": "CST", + "Shanghai": "CST", + "Manila": "PHT", + "Bangkok": "ICT", + "Hanoi": "ICT", + "New Delhi": "IST", + "Colombo": "IST", + "Kathmandu": "NPT", + "Dhaka": "BST", + "Islamabad": "PKT", + }; + + const cityKey = parts[parts.length - 1]; + if (countryFromCity[cityKey]) return countryFromCity[cityKey]; + + // Fallback: return the timezone itself + return timezone; +} + +// ── Cache helpers ────────────────────────────────────────────────────────── +const CACHE_DIR = "/tmp/prayer-cache"; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +function cacheKey(city: string, country: string): string { + return `${city.toLowerCase().replace(/\s+/g, "-")}-${country.toLowerCase().replace(/\s+/g, "-")}`; +} + +function cachePath(key: string): string { + return join(CACHE_DIR, `${key}.json`); +} + +interface CacheEntry { + cachedAt: number; + data: AlAdhanResponse; +} + +interface AlAdhanTimings { + Fajr: string; + Sunrise: string; + Dhuhr: string; + Asr: string; + Sunset: string; + Maghrib: string; + Isha: string; + Imsak: string; + Midnight: string; + Firstthird: string; + Lastthird: string; +} + +interface AlAdhanDateInfo { + readable: string; + timestamp: string; + gregorian: { + date: string; + format: string; + day: string; + weekday: { en: string }; + month: { number: number; en: string }; + year: string; + designation: { abbreviated: string; expanded: string }; + }; + hijri: { + date: string; + format: string; + day: string; + weekday: { en: string; ar: string }; + month: { number: number; en: string; ar: string }; + year: string; + designation: { abbreviated: string; expanded: string }; + holidays: string[]; + }; +} + +interface AlAdhanMeta { + latitude: number; + longitude: number; + timezone: string; + method: { id: number; name: string }; + latitudeAdjustment: string; + midnightMode: string; + school: string; + offset: Record; +} + +interface AlAdhanResponse { + code: number; + status: string; + data: { + timings: AlAdhanTimings; + date: AlAdhanDateInfo; + meta: AlAdhanMeta; + }; +} + +interface PrayerTimings { + Fajr: string; + Sunrise?: string; + Dhuhr: string; + Asr: string; + Sunset?: string; + Maghrib: string; + Isha: string; + [key: string]: string | undefined; +} + +interface PrayerResponse { + timings: Record; + date: string; + hijri: string; + city: string; + country: string; + timezone: string; +} + +// ── Fallback defaults ────────────────────────────────────────────────────── +function getFallbackResponse(city: string, country: string): PrayerResponse { + const timezone = city === "Kuala Lumpur" && country === "Malaysia" + ? "Asia/Kuala_Lumpur" + : `Asia/${city.replace(/\s/g, "_")}`; + const tzAbbr = getTimezoneAbbr(timezone); + + return { + timings: { + Fajr: `05:42 (${tzAbbr})`, + Dhuhr: `13:15 (${tzAbbr})`, + Asr: `16:30 (${tzAbbr})`, + Maghrib: `19:22 (${tzAbbr})`, + Isha: `20:39 (${tzAbbr})`, + }, + date: new Date().toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }), + hijri: "—", + city, + country, + timezone, + }; +} + +// ── Main handler ─────────────────────────────────────────────────────────── +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const city = searchParams.get("city") || "Kuala Lumpur"; + const country = searchParams.get("country") || "Malaysia"; + const key = cacheKey(city, country); + const cPath = cachePath(key); + + // 1. Try reading from cache + try { + if (existsSync(cPath)) { + const raw = await readFile(cPath, "utf-8"); + const entry: CacheEntry = JSON.parse(raw); + const age = Date.now() - entry.cachedAt; + + // If cache is still fresh, return it + if (age < CACHE_TTL_MS) { + return Response.json(formatResponse(entry.data, city, country)); + } + + // If cache is stale but we have it, we'll try to refresh but fall back to stale + try { + const fresh = await fetchAlAdhan(city, country); + await saveCache(cPath, fresh); + return Response.json(formatResponse(fresh, city, country)); + } catch { + // API failed; return stale cache + return Response.json(formatResponse(entry.data, city, country)); + } + } + } catch { + // Cache read error — proceed to fetch fresh + } + + // 2. No cache — fetch from alAdhan + try { + const data = await fetchAlAdhan(city, country); + await saveCache(cPath, data); + return Response.json(formatResponse(data, city, country)); + } catch (error) { + console.error("Prayer times API error:", error); + + // 3. Try stale cache even if we already checked (race condition guard) + try { + if (existsSync(cPath)) { + const raw = await readFile(cPath, "utf-8"); + const entry: CacheEntry = JSON.parse(raw); + return Response.json(formatResponse(entry.data, city, country)); + } + } catch { + // ignore + } + + // 4. Last resort: fallback defaults + return Response.json(getFallbackResponse(city, country)); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +async function fetchAlAdhan( + city: string, + country: string +): Promise { + const url = `https://api.aladhan.com/v1/timingsByCity?city=${encodeURIComponent(city)}&country=${encodeURIComponent(country)}&method=2&adjustment=1`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + next: { revalidate: 3600 }, + }); + + if (!res.ok) { + throw new Error(`alAdhan API returned ${res.status}: ${await res.text()}`); + } + + const json: AlAdhanResponse = await res.json(); + + if (json.code !== 200 || json.status !== "OK") { + throw new Error(`alAdhan API error: ${json.status} (code ${json.code})`); + } + + return json; +} + +async function saveCache(path: string, data: AlAdhanResponse): Promise { + try { + await mkdir(CACHE_DIR, { recursive: true }); + const entry: CacheEntry = { cachedAt: Date.now(), data }; + await writeFile(path, JSON.stringify(entry), "utf-8"); + } catch (err) { + console.error("Failed to write prayer cache:", err); + } +} + +function formatResponse( + apiData: AlAdhanResponse, + city: string, + country: string +): PrayerResponse { + const { timings, date, meta } = apiData.data; + const tzAbbr = getTimezoneAbbr(meta.timezone); + + // The alAdhan API returns times like "05:42 (MST)" already if adjustment is set, + // but we'll append our own timezone label to be safe + const prayerOrder = ["Fajr", "Sunrise", "Dhuhr", "Asr", "Sunset", "Maghrib", "Isha", "Imsak", "Midnight"]; + const formattedTimings: Record = {}; + + for (const prayer of prayerOrder) { + const time = (timings as unknown as Record)[prayer]; + if (time) { + // Strip any existing timezone label in parentheses and re-add ours + const cleanTime = time.replace(/\s*\(.*?\)\s*/g, "").trim(); + formattedTimings[prayer] = `${cleanTime} (${tzAbbr})`; + } + } + + return { + timings: formattedTimings, + date: date.readable, + hijri: `${date.hijri.day} ${date.hijri.month.en} ${date.hijri.year}`, + city, + country, + timezone: meta.timezone, + }; +} diff --git a/src/app/dhikr/page.tsx b/src/app/dhikr/page.tsx new file mode 100644 index 0000000..66c21a1 --- /dev/null +++ b/src/app/dhikr/page.tsx @@ -0,0 +1,465 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { + RotateCcw, BarChart3, Check, Plus, Minus, + Circle, ChevronRight, Loader2, +} from "lucide-react"; + +// ── Types ── +interface DhikrPreset { + id: string; + label: string; + arabic: string; + transliteration: string; + translation: string; + target: number; +} + +interface DayStats { + subhanallah: number; + alhamdulillah: number; + allahuakbar: number; + custom: number; + totalCount: number; + sessionCount: number; +} + +interface RecentSession { + id: string; + type: string; + count: number; + createdAt: string; +} + +interface StatsResponse { + dayStats: { subhanallah: number; alhamdulillah: number; allahuakbar: number; custom: number; totalCount: number; sessionCount: number }; + recentSessions: RecentSession[]; +} + +// ── Constants ── +const PRESETS: DhikrPreset[] = [ + { id: "subhanallah", label: "SubhanAllah", arabic: "سُبْحَانَ ٱللَّٰهِ", transliteration: "SubhanAllah", translation: "Glory be to Allah", target: 33 }, + { id: "alhamdulillah", label: "Alhamdulillah", arabic: "ٱلْحَمْدُ لِلَّٰهِ", transliteration: "Alhamdulillah", translation: "Praise be to Allah", target: 33 }, + { id: "allahuakbar", label: "Allahu Akbar", arabic: "ٱللَّٰهُ أَكْبَرُ", transliteration: "Allahu Akbar", translation: "Allah is the Greatest", target: 34 }, +]; + +const COLORS: Record = { + subhanallah: { from: "from-emerald-900/30", to: "to-emerald-800/10", accent: "emerald", ring: "#10b981" }, + alhamdulillah: { from: "from-amber-900/30", to: "to-amber-800/10", accent: "amber", ring: "#f59e0b" }, + allahuakbar: { from: "from-sky-900/30", to: "to-sky-800/10", accent: "sky", ring: "#0ea5e9" }, + custom: { from: "from-purple-900/30", to: "to-purple-800/10", accent: "purple", ring: "#a855f7" }, +}; + +const ALL_TARGETS = [ + { label: "33", value: 33 }, + { label: "99", value: 99 }, + { label: "100", value: 100 }, + { label: "500", value: 500 }, + { label: "1K", value: 1000 }, +]; + +// ── Circular Progress ── +function CircularRing({ percent, size = 64, strokeWidth = 4, color = "#10b981" }: { percent: number; size?: number; strokeWidth?: number; color?: string }) { + const r = (size - strokeWidth) / 2; + const circ = 2 * Math.PI * r; + const offset = circ * (1 - Math.min(percent, 1)); + return ( + + + + + ); +} + +// ── Main Component ── +export default function DhikrPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + + // State + const [selectedPreset, setSelectedPreset] = useState("subhanallah"); + const [count, setCount] = useState(0); + const [customTarget, setCustomTarget] = useState(33); + const [mode, setMode] = useState<"preset" | "custom">("preset"); + const [showTargetPicker, setShowTargetPicker] = useState(false); + const [dayStats, setDayStats] = useState(null); + const [recentSessions, setRecentSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showRecent, setShowRecent] = useState(false); + const [showReset, setShowReset] = useState(false); + + const buttonRef = useRef(null); + const [ripples, setRipples] = useState<{ id: number; x: number; y: number }[]>([]); + const nextRippleId = useRef(0); + + const lastTapRef = useRef(0); + const countRef = useRef(count); + countRef.current = count; + + // ── Auth redirect ── + useEffect(() => { + if (!authLoading && !token) router.push("/auth"); + }, [authLoading, token, router]); + + // ── Fetch stats ── + const fetchStats = useCallback(async () => { + if (!token) return; + try { + const res = await fetch("/mobile/api/dhikr/stats", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data: StatsResponse = await res.json(); + setDayStats(data.dayStats); + setRecentSessions(data.recentSessions); + } + } catch {} finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { if (token) fetchStats(); }, [token, fetchStats]); + + // ── Log session to API ── + const logSession = useCallback(async (type: string, cnt: number, tgt: number) => { + if (!token || cnt === 0) return; + setSaving(true); + try { + await fetch("/mobile/api/dhikr/stats", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ type, count: cnt, target: tgt, completed: true }), + }); + await fetchStats(); + } catch {} finally { + setSaving(false); + } + }, [token, fetchStats]); + + // ── Tap counter ── + const handleTap = (e: React.MouseEvent | React.TouchEvent) => { + // Haptic feedback + if (navigator.vibrate) navigator.vibrate(10); + + // Ripple effect + const rect = buttonRef.current?.getBoundingClientRect(); + if (rect) { + let x: number, y: number; + if ("touches" in e) { + x = e.touches[0].clientX - rect.left; + y = e.touches[0].clientY - rect.top; + } else { + x = e.clientX - rect.left; + y = e.clientY - rect.top; + } + const id = nextRippleId.current++; + setRipples((prev) => [...prev.slice(-4), { id, x, y }]); + setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 600); + } + + setCount((c) => c + 1); + lastTapRef.current = Date.now(); + }; + + // ── Long-press -> reset ── + const longPressTimer = useRef | null>(null); + const handlePointerDown = () => { + longPressTimer.current = setTimeout(() => setShowReset(true), 500); + }; + const handlePointerUp = () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }; + + // ── Reset ── + const resetCount = () => { + setCount(0); + setShowReset(false); + }; + + // ── Switch preset ── + const switchPreset = (id: string) => { + if (count > 0) { + // Auto-save current session + const preset = PRESETS.find((p) => p.id === selectedPreset); + const type = preset ? preset.id : "custom"; + const tgt = preset ? preset.target : customTarget; + logSession(type, count, tgt); + } + setSelectedPreset(id); + setCount(0); + setMode("preset"); + }; + + // ── Start custom mode ── + const startCustom = () => { + if (count > 0) { + const preset = PRESETS.find((p) => p.id === selectedPreset); + logSession(preset ? preset.id : "custom", count, preset ? preset.target : customTarget); + } + setMode("custom"); + setCount(0); + }; + + const target = mode === "custom" ? customTarget : (PRESETS.find((p) => p.id === selectedPreset)?.target ?? 33); + const percent = target > 0 ? count / target : 0; + const isComplete = count > 0 && count >= target; + const currentPreset = PRESETS.find((p) => p.id === selectedPreset); + const colors = COLORS[mode === "custom" ? "custom" : selectedPreset] || COLORS.custom; + + // ── Render ── + return ( +
+ {/* ── Header ── */} +
+
+

+ 📿 Dhikr +

+

Digital Tasbih Counter

+
+
+ + +
+
+ + {/* ── Preset Selector ── */} +
+
+ {PRESETS.map((p) => { + const active = selectedPreset === p.id && mode === "preset"; + const c = COLORS[p.id]; + return ( + + ); + })} + +
+
+ + {/* ── Count Display & Counter ── */} +
+
+ {/* Decorative glow */} +
+ + {/* Arabic text + progress */} +
+ {/* Circular progress + count */} +
+ +
+ {isComplete ? ( +
+ +

Complete!

+
+ ) : ( + <> +

{count}

+

/ {target}

+ + )} +
+
+ + {/* Arabic dhikr */} + {mode === "preset" && currentPreset && ( +

{currentPreset.arabic}

+ )} + {mode === "custom" && ( +

Custom Dhikr

+ )} + {mode === "preset" && currentPreset && ( +

{currentPreset.translation}

+ )} + {mode === "custom" && ( +

Set your own count target

+ )} + + {/* Tap Button */} + + + {/* +/- Buttons */} +
+ + + {mode === "custom" && ( + + )} + + +
+ + {/* Complete button */} + {count > 0 && ( + + )} +
+
+
+ + {/* ── Stats / Recent Sessions Panel ── */} + {showRecent && dayStats && ( +
+
+

Today's Dhikr

+
+ + + + +
+ + +
+
+ + {recentSessions.length > 0 && ( + <> +

Recent Sessions

+
+ {recentSessions.slice(0, 10).map((s) => ( +
+ {s.type} + +{s.count} +
+ ))} +
+ + )} +
+
+ )} + + {/* ── Target Picker Modal (custom mode) ── */} + {showTargetPicker && ( +
setShowTargetPicker(false)}> +
e.stopPropagation()}> +

Set Target

+
+ {ALL_TARGETS.map((t) => ( + + ))} +
+

Or close to keep current ({customTarget})

+
+
+ )} + + {/* ── Reset confirmation ── */} + {showReset && ( +
setShowReset(false)}> +
e.stopPropagation()}> +

Reset counter? Current count: {count}

+
+ + +
+
+
+ )} + + {/* ── Loader ── */} + {(authLoading || loading) && ( +
+ +
+ )} +
+ ); +} + +function StatRow({ label, value, icon, highlight }: { label: string; value: number; icon: string; highlight?: boolean }) { + return ( +
+ + {icon} {label} + + + {value.toLocaleString()} + +
+ ); +} diff --git a/src/app/halal-monitor/page.tsx b/src/app/halal-monitor/page.tsx index 23c0c9a..b1dc173 100644 --- a/src/app/halal-monitor/page.tsx +++ b/src/app/halal-monitor/page.tsx @@ -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 = { all: "All", @@ -53,7 +99,9 @@ const TYPE_LABELS: Record = { 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(null); const [pageLoading, setPageLoading] = useState(true); const [usageLoading, setUsageLoading] = useState(false); - const [mapReady, setMapReady] = useState(false); - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const markersRef = useRef([]); + const [userLocation, setUserLocation] = useState(null); + const [locationError, setLocationError] = useState(null); + const [icons, setIcons] = useState | 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: `
🕌
`, + className: "", + iconSize: [36, 36], + iconAnchor: [18, 36], + popupAnchor: [0, -36], + }), + restaurant: L.divIcon({ + html: `
🍽️
`, + className: "", + iconSize: [36, 36], + iconAnchor: [18, 36], + popupAnchor: [0, -36], + }), + cafe: L.divIcon({ + html: `
`, + className: "", + iconSize: [36, 36], + iconAnchor: [18, 36], + popupAnchor: [0, -36], + }), + user: L.divIcon({ + html: `
`, + 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 ( {Array.from({ length: full }, (_, i) => ( - + ))} {half && (
@@ -249,12 +385,19 @@ export default function HalalMonitorPage() { {Array.from({ length: empty }, (_, i) => ( ))} - {rating.toFixed(1)} + + {rating.toFixed(1)} + ); }; - // ── Premium Gating Overlay ───────────────────────────────────────────── + // ── Map center ──────────────────────────────────────────── + const mapCenter: [number, number] = userLocation + ? [userLocation.lat, userLocation.lng] + : DEFAULT_CENTER; + + // ── Premium Gating Overlay ───────────────────────────────── if (loading) { return (
@@ -267,7 +410,7 @@ export default function HalalMonitorPage() { return (
- {/* ── Header ─────────────────────────────────────────────────────── */} + {/* ── Header ───────────────────────────────────────── */}

@@ -286,7 +429,12 @@ export default function HalalMonitorPage() {
- {/* ── Premium Upgrade Banner ──────────────────────────────────── */} + {/* ── Premium Upgrade Banner ─────────────────────── */} {isFree && (

@@ -326,12 +474,12 @@ export default function HalalMonitorPage() { {isFree && (
- Free tier: mosques only • 10 queries/day + Free tier: 10 queries/day
)}
- {/* ── Search Bar ─────────────────────────────────────────────────── */} + {/* ── Search Bar ───────────────────────────────────── */}
@@ -344,19 +492,25 @@ export default function HalalMonitorPage() { className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none" /> {search && ( - )}
- {/* ── Category Filter ────────────────────────────────────────────── */} + {/* ── Category Filter ──────────────────────────────── */}
{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 (
- {/* ── Map Placeholder ────────────────────────────────────────────── */} + {/* ── Leaflet Map ──────────────────────────────────── */}
-
- {/* Decorative grid lines */} -
- {/* Center pin */} -
-
-
- -
- - {visiblePlaces.length} places found - -
-
- - {/* Map labels */} - {visiblePlaces.slice(0, 5).map((place) => ( -
+ + - ))} + {/* User location blue dot */} + {icons && userLocation && ( + + You are here + + )} + {/* Place markers */} + {icons && + visiblePlaces.map((place) => ( + setSelectedPlace(place), + }} + > + +
{place.name}
+
+ {place.address} +
+
+
+ ))} +
- {/* ── Results List ───────────────────────────────────────────────── */} + {/* ── Results List ─────────────────────────────────── */}

- {visiblePlaces.length} {visiblePlaces.length === 1 ? "place" : "places"} + {visiblePlaces.length}{" "} + {visiblePlaces.length === 1 ? "place" : "places"} + {userLocation && " · Nearest first"}

{usageLoading && ( @@ -437,9 +598,7 @@ export default function HalalMonitorPage() {

No places found

- {isFree - ? "Free tier shows mosques only. Try a different search." - : "Try adjusting your search or filters."} + Try adjusting your search or filters.

) : ( @@ -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"}
@@ -478,13 +641,22 @@ export default function HalalMonitorPage() { {/* Rating */}
{renderStars(place.rating)}
- {/* Halal badge */} - {place.halal_certified && ( - - - Halal Certified - - )} + {/* Distance & Halal badge */} +
+ {place.distance !== undefined && ( + + {place.distance < 1 + ? `${(place.distance * 1000).toFixed(0)} m` + : `${place.distance.toFixed(1)} km`} + + )} + {place.halal_certified && ( + + + Halal Certified + + )} +
{/* Bookmark heart */} @@ -512,7 +684,7 @@ export default function HalalMonitorPage() { )}
- {/* ── Detail Modal ────────────────────────────────────────────────── */} + {/* ── Detail Modal ──────────────────────────────────── */} {selectedPlace && (
- {selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"} + {selectedPlace.type === "mosque" + ? "🕌 Mosque" + : selectedPlace.type === "restaurant" + ? "🍽️ Restaurant" + : "☕ Cafe"}
@@ -569,7 +745,9 @@ export default function HalalMonitorPage() { {/* Address */}
-

{selectedPlace.address}

+

+ {selectedPlace.address} +

{/* Rating */} @@ -581,6 +759,19 @@ export default function HalalMonitorPage() { / 5.0
+ {/* Distance (if available) */} + {selectedPlace.distance !== undefined && ( +
+ + + {selectedPlace.distance < 1 + ? `${(selectedPlace.distance * 1000).toFixed(0)} m` + : `${selectedPlace.distance.toFixed(1)} km`}{" "} + from your location + +
+ )} + {/* Coords */}
@@ -597,6 +788,17 @@ export default function HalalMonitorPage() {
+ {/* Open in Google Maps */} + + + Open in Google Maps + + {/* Bookmark button */}
+ {/* Dhikr Today */} +
+ +
+
+
+ +
+
+

Dhikr Today

+

Digital Tasbih Counter

+
+
+
+

{dhikrTodayCount.toLocaleString()}

+

counts

+
+
+
+ سُبْحَانَ ٱللَّٰهِ + · + ٱلْحَمْدُ لِلَّٰهِ + · + ٱللَّٰهُ أَكْبَرُ +
+ +
+ + {/* Qibla Finder */} +
+ +
+
+
+ +
+
+

Qibla Finder

+

Find direction to the Kaaba

+
+
+ +
+ +
+ {/* Stats Row */}
diff --git a/src/app/prayer/page.tsx b/src/app/prayer/page.tsx new file mode 100644 index 0000000..ca0f06f --- /dev/null +++ b/src/app/prayer/page.tsx @@ -0,0 +1,832 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + MapPin, + Settings2, + Moon, + Sun, + Sunset, + CloudMoon, + Bell, + BellOff, + Volume2, + VolumeX, + Clock, + RefreshCw, + Loader2, + ChevronRight, + X, + Check, + Navigation, +} from "lucide-react"; + +// ── Types ── +interface PrayerTimings { + Fajr: string; + Dhuhr: string; + Asr: string; + Maghrib: string; + Isha: string; +} + +interface PrayerApiResponse { + code: number; + data: { + timings: PrayerTimings; + date: { + readable: string; + gregorian: { date: string }; + hijri?: { date: string; month: { en: string }; day: string }; + }; + meta: { + timezone: string; + method: { name: string }; + latitude: number; + longitude: number; + }; + }; +} + +interface LocationSettings { + city: string; + country: string; +} + +// ── Constants ── +const PRAYER_ORDER: (keyof PrayerTimings)[] = [ + "Fajr", + "Dhuhr", + "Asr", + "Maghrib", + "Isha", +]; + +const PRAYER_INFO: Record< + keyof PrayerTimings, + { label: string; icon: typeof Moon; subtitle: string } +> = { + Fajr: { label: "Fajr", icon: Moon, subtitle: "Dawn" }, + Dhuhr: { label: "Dhuhr", icon: Sun, subtitle: "Noon" }, + Asr: { label: "Asr", icon: Sun, subtitle: "Afternoon" }, + Maghrib: { label: "Maghrib", icon: Sunset, subtitle: "Sunset" }, + Isha: { label: "Isha", icon: CloudMoon, subtitle: "Night" }, +}; + +const DEFAULT_LOCATION: LocationSettings = { + city: "Kuala Lumpur", + country: "MY", +}; + +// ── Helpers ── +function parseTimeToMs(timeStr: string, dayOffset = 0): number { + const [h, m] = timeStr.split(":").map(Number); + const d = new Date(); + d.setDate(d.getDate() + dayOffset); + d.setHours(h, m, 0, 0); + return d.getTime(); +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return "0m 0s"; + const hours = Math.floor(ms / 3600000); + const minutes = Math.floor((ms % 3600000) / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function getNextPrayerInfo(timings: PrayerTimings | null): { + name: keyof PrayerTimings | null; + label: string; + time: string; + remaining: number; + progress: number; +} { + if (!timings) { + return { name: null, label: "", time: "", remaining: 0, progress: 0 }; + } + + const now = Date.now(); + + // Find next prayer today + let nextName: keyof PrayerTimings | null = null; + let nextTimeStr = ""; + let nextTimeMs = Infinity; + + for (const name of PRAYER_ORDER) { + const t = parseTimeToMs(timings[name]); + if (t > now && t < nextTimeMs) { + nextTimeMs = t; + nextName = name; + nextTimeStr = timings[name]; + } + } + + let prevTimeMs: number; + + if (!nextName) { + // All prayers passed → next is tomorrow's Fajr + nextName = "Fajr"; + nextTimeStr = timings.Fajr; + nextTimeMs = parseTimeToMs(timings.Fajr, 1); + prevTimeMs = parseTimeToMs(timings.Isha); + } else { + const idx = PRAYER_ORDER.indexOf(nextName); + if (idx === 0) { + // Before Fajr today → previous was yesterday's Isha + const ishaMs = parseTimeToMs(timings.Isha); + const yesterdayIsha = new Date(ishaMs); + yesterdayIsha.setDate(yesterdayIsha.getDate() - 1); + prevTimeMs = yesterdayIsha.getTime(); + } else { + prevTimeMs = parseTimeToMs(timings[PRAYER_ORDER[idx - 1]]); + } + } + + const remaining = Math.max(0, nextTimeMs - now); + const totalInterval = nextTimeMs - prevTimeMs; + const elapsed = now - prevTimeMs; + const progress = + totalInterval > 0 + ? Math.min(Math.max(elapsed / totalInterval, 0), 1) + : 0; + + return { + name: nextName, + label: PRAYER_INFO[nextName].label, + time: nextTimeStr, + remaining, + progress, + }; +} + +function getDayProgress(): number { + const now = new Date(); + const start = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 0, + 0, + 0, + 0 + ); + const end = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 23, + 59, + 59, + 999 + ); + const elapsed = now.getTime() - start.getTime(); + const total = end.getTime() - start.getTime(); + return total > 0 ? elapsed / total : 0; +} + +// ── SVG Circular Progress ── +function CircularProgress({ + percent, + size = 80, + strokeWidth = 5, +}: { + percent: number; + size?: number; + strokeWidth?: number; +}) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - percent); + return ( + + {/* Background circle */} + + {/* Progress arc */} + + + + + + + + + ); +} + +// ── Main Component ── +export default function PrayerPage() { + const [timings, setTimings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [location, setLocation] = useState(() => { + if (typeof window === "undefined") return DEFAULT_LOCATION; + try { + const stored = localStorage.getItem("prayer_location"); + return stored ? JSON.parse(stored) : DEFAULT_LOCATION; + } catch { + return DEFAULT_LOCATION; + } + }); + const [notifications, setNotifications] = useState(() => { + if (typeof window === "undefined") return true; + return localStorage.getItem("prayer_notifications") !== "false"; + }); + const [adhanSound, setAdhanSound] = useState(() => { + if (typeof window === "undefined") return true; + return localStorage.getItem("prayer_adhan_sound") !== "false"; + }); + const [showSettings, setShowSettings] = useState(false); + const [editCity, setEditCity] = useState(""); + const [editCountry, setEditCountry] = useState(""); + const [editLoading, setEditLoading] = useState(false); + const [now, setNow] = useState(Date.now()); + const [showHijri, setShowHijri] = useState(false); + const [hijriDate, setHijriDate] = useState(""); + const [refreshing, setRefreshing] = useState(false); + + // ── Fetch prayer times ── + const fetchPrayerTimes = useCallback( + async (city: string, country: string) => { + setError(null); + setLoading(true); + try { + const res = await fetch( + `/mobile/api/prayer?city=${encodeURIComponent(city)}&country=${encodeURIComponent(country)}` + ); + if (!res.ok) { + const text = await res.text().catch(() => "Unknown error"); + throw new Error(`API error ${res.status}: ${text}`); + } + const json: PrayerApiResponse = await res.json(); + if (json.code === 200 && json.data) { + setTimings(json.data.timings); + const hijri = json.data.date?.hijri; + if (hijri) { + setHijriDate(`${hijri.date} ${hijri.month?.en || ""}`); + setShowHijri(true); + } + } else { + throw new Error("Invalid response from prayer API"); + } + } catch (err: any) { + setError(err.message || "Failed to load prayer times"); + } finally { + setLoading(false); + } + }, + [] + ); + + // ── Initial fetch and refresh ── + useEffect(() => { + fetchPrayerTimes(location.city, location.country); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Refresh handler ── + const handleRefresh = async () => { + setRefreshing(true); + await fetchPrayerTimes(location.city, location.country); + setTimeout(() => setRefreshing(false), 500); + }; + + // ── Countdown tick ── + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, []); + + // ── Persist toggles ── + useEffect(() => { + localStorage.setItem("prayer_notifications", String(notifications)); + }, [notifications]); + + useEffect(() => { + localStorage.setItem("prayer_adhan_sound", String(adhanSound)); + }, [adhanSound]); + + // ── Compute next prayer ── + const nextPrayer = getNextPrayerInfo(timings); + const dayProgress = getDayProgress(); + + // ── Current prayer index (for highlighting) ── + const currentPrayerIndex = (() => { + if (!timings) return -1; + const nowMs = Date.now(); + for (let i = PRAYER_ORDER.length - 1; i >= 0; i--) { + const t = parseTimeToMs(timings[PRAYER_ORDER[i]]); + if (t <= nowMs) return i; + } + return -1; + })(); + + // ── Settings modal ── + const openSettings = () => { + setEditCity(location.city); + setEditCountry(location.country); + setShowSettings(true); + }; + + const saveSettings = async () => { + const trimmedCity = editCity.trim(); + const trimmedCountry = editCountry.trim().toUpperCase(); + if (!trimmedCity || !trimmedCountry) return; + + setEditLoading(true); + const newLoc = { city: trimmedCity, country: trimmedCountry }; + setLocation(newLoc); + localStorage.setItem("prayer_location", JSON.stringify(newLoc)); + await fetchPrayerTimes(newLoc.city, newLoc.country); + setEditLoading(false); + setShowSettings(false); + }; + + // ── Countdown display ── + const countdownText = formatCountdown(nextPrayer.remaining); + const countdownValue = nextPrayer.remaining; + + // ── Render ── + return ( +
+ {/* ── Header ── */} +
+
+

+ 🕌 Prayer Times +

+ +
+ + {/* Location bar */} + + + {/* Hijri date */} + {showHijri && hijriDate && ( +

+ 📅 {hijriDate} +

+ )} +
+ + {/* ── Loading State ── */} + {loading && ( +
+ +

Fetching prayer times...

+
+ )} + + {/* ── Error State ── */} + {!loading && error && ( +
+
+

{error}

+ +
+
+ )} + + {/* ── Content ── */} + {!loading && !error && timings && ( + <> + {/* ── Next Prayer Card ── */} +
+
+ {/* Decorative glow */} +
+
+ +
+
+

+ Next Prayer +

+
+ + + {countdownValue <= 3600000 + ? "Due soon" + : countdownText.includes("h") + ? `${countdownText.split("h")[0]}h until` + : `${countdownText.split("m")[0]}m until`} + +
+
+ +
+
+

+ {nextPrayer.label} +

+
+ + {nextPrayer.time} + + + {PRAYER_INFO[nextPrayer.name!]?.subtitle ?? ""} + +
+
+ + {countdownText} + + + remaining + +
+
+ + {/* Prayer icon */} +
+ {nextPrayer.name && + (() => { + const Icon = PRAYER_INFO[nextPrayer.name].icon; + return ( + + ); + })()} +
+
+ + {/* Progress bar */} +
+
+
+
+
+ + {Math.round(nextPrayer.progress * 100)}% + + + towards {nextPrayer.label} + +
+
+
+
+
+ + {/* ── Day Progress & Prayer Grid Row ── */} +
+ {/* Day progress circle */} +
+ +

Day Progress

+

+ {Math.round(dayProgress * 100)}% +

+
+ + {/* Time info */} +
+

Current Time

+

+ {new Date(now).toLocaleTimeString("en-MY", { + hour: "2-digit", + minute: "2-digit", + })} +

+

+ {new Date(now).toLocaleDateString("en-MY", { + weekday: "long", + day: "numeric", + month: "long", + })} +

+
+
+ + {/* ── Daily Prayer Times ── */} +
+

+ Daily Prayers +

+
+ {PRAYER_ORDER.map((name, idx) => { + const isCurrent = idx === currentPrayerIndex; + const isNext = name === nextPrayer.name; + const Icon = PRAYER_INFO[name].icon; + return ( +
+
+
+ +
+
+

+ {PRAYER_INFO[name].label} +

+

+ {PRAYER_INFO[name].subtitle} +

+
+
+ +
+ + {timings[name]} + + {isNext && ( + + Next + + )} + {isCurrent && !isNext && ( + + Now + + )} +
+
+ ); + })} +
+
+ + {/* ── Settings Toggles ── */} +
+

+ Preferences +

+ +
+ {/* Prayer Notifications */} +
+
+
+ {notifications ? ( + + ) : ( + + )} +
+
+

+ Prayer Notifications +

+

+ Get reminded at prayer times +

+
+
+ +
+ + {/* Adhan Sound */} +
+
+
+ {adhanSound ? ( + + ) : ( + + )} +
+
+

+ Adhan Sound +

+

+ Play the call to prayer +

+
+
+ +
+
+
+ + {/* ── Location Card (quick edit) ── */} +
+ +
+ + )} + + {/* ── No timings but loaded state ── */} + {!loading && !error && !timings && ( +
+
+ +

No prayer data available

+

+ Pull down to refresh or check your location settings +

+ +
+
+ )} + + {/* ── Settings Modal ── */} + {showSettings && ( +
+ {/* Backdrop */} +
!editLoading && setShowSettings(false)} + /> + + {/* Sheet */} +
+
+

+ + Location Settings +

+ +
+ +
+
+ + setEditCity(e.target.value)} + placeholder="e.g. Kuala Lumpur" + className="w-full bg-[#0a0a0f] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 outline-none focus:border-[#D4AF37]/40 transition" + /> +
+
+ + setEditCountry(e.target.value.toUpperCase())} + placeholder="e.g. MY" + maxLength={2} + className="w-full bg-[#0a0a0f] border border-gray-800 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 outline-none focus:border-[#D4AF37]/40 transition uppercase" + /> +

+ Two-letter ISO country code +

+
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/app/qibla/page.tsx b/src/app/qibla/page.tsx new file mode 100644 index 0000000..9e9267d --- /dev/null +++ b/src/app/qibla/page.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import { + Compass, MapPin, Navigation, LocateFixed, + Locate, Loader2, AlertTriangle, Info, RefreshCw, +} from "lucide-react"; +import { qiblaBearing, distanceToMecca, bearingToDirection, formatDistance, KAABA_LAT, KAABA_LNG } from "@/lib/qibla"; + +// ── Types ── +interface GeoPosition { + lat: number; + lng: number; + accuracy: number; +} + +// ── Compass SVG ── +function CompassRose({ qiblaAngle, deviceHeading, size = 260 }: { qiblaAngle: number; deviceHeading: number | null; size?: number }) { + const cx = size / 2; + const cy = size / 2; + const outerR = size / 2 - 8; + const innerR = size / 2 - 28; + const arrowLen = outerR - 16; + + // The compass dial always points North up. Rotation of the dial to match + // device heading is handled by CSS transform on the container. + // qiblaAngle is the bearing from user to Mecca measured clockwise from True North. + // So the Qibla arrow is drawn at qiblaAngle degrees from the top (North). + + return ( +
+ {/* Rotating compass face: when deviceHeading is available, rotate opposite so North aligns */} +
+ + + + + + + + + {/* Outer ring glow */} + + + {/* Outer ring */} + + + + {/* Cardinal direction lines & labels */} + {[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => { + const rad = (deg - 90) * (Math.PI / 180); + const x1 = cx + innerR * Math.cos(rad); + const y1 = cy + innerR * Math.sin(rad); + const x2 = cx + outerR * Math.cos(rad); + const y2 = cy + outerR * Math.sin(rad); + const isCardinal = deg % 90 === 0; + const labels: Record = { 0: "N", 90: "E", 180: "S", 270: "W" }; + const label = labels[deg] || ""; + const lx = cx + (outerR + 14) * Math.cos(rad); + const ly = cy + (outerR + 14) * Math.sin(rad); + + return ( + + + {label && ( + + {label} + + )} + + ); + })} + + {/* Inner degree ticks (every 10°) */} + {Array.from({ length: 36 }, (_, i) => i * 10).filter((d) => d % 45 !== 0).map((deg) => { + const rad = (deg - 90) * (Math.PI / 180); + const x1 = cx + (innerR + 5) * Math.cos(rad); + const y1 = cy + (innerR + 5) * Math.sin(rad); + const x2 = cx + innerR * Math.cos(rad); + const y2 = cy + innerR * Math.sin(rad); + return ( + + ); + })} + + + {/* Qibla arrow — drawn above the compass but rotates with it */} +
+
+ {/* Arrow pointing toward Mecca */} + + {/* Arrow shaft */} + + {/* Arrow head */} + + {/* Glow dot at center */} + + + + + +
+
+
+ + {/* Fixed Kaaba icon at center (doesn't rotate) */} +
+
+ + + + + +
+
+ + {/* Fixed North indicator chevron at top (doesn't rotate) */} +
+ + + +
+
+ ); +} + +// ── Main Component ── +export default function QiblaPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [position, setPosition] = useState(null); + const [positionError, setPositionError] = useState(null); + const [geolocating, setGeolocating] = useState(true); + const [deviceHeading, setDeviceHeading] = useState(null); + const [compassAvailable, setCompassAvailable] = useState(false); + const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied" | "unavailable">("prompt"); + const [showInfo, setShowInfo] = useState(false); + const watchIdRef = useRef(null); + const orientationRef = useRef(null); + + // ── Auth redirect ── + useEffect(() => { + if (!authLoading && !token) router.push("/auth"); + }, [authLoading, token, router]); + + // ── Geolocation ── + const startGeolocation = useCallback(() => { + if (!navigator.geolocation) { + setPositionError("Geolocation not available on this device"); + setGeolocating(false); + return; + } + + setGeolocating(true); + setPositionError(null); + + // One-shot high accuracy + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracy: pos.coords.accuracy, + }); + setGeolocating(false); + setPositionError(null); + }, + (err) => { + setPositionError( + err.code === 1 ? "Location permission denied — enable in settings" + : err.code === 2 ? "Location unavailable — try again" + : "Failed to get location" + ); + setGeolocating(false); + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 } + ); + }, []); + + useEffect(() => { + startGeolocation(); + }, [startGeolocation]); + + // ── Device Orientation (Compass) ── + useEffect(() => { + const checkPermission = async () => { + // Check if DeviceOrientationEvent exists + if (!window.DeviceOrientationEvent) { + setCompassAvailable(false); + setPermissionState("unavailable"); + return; + } + + // iOS 13+ requires permission request + if (typeof (DeviceOrientationEvent as any).requestPermission === "function") { + // We'll request on user interaction — for now mark as prompt + setPermissionState("prompt"); + setCompassAvailable(true); + return; + } + + // Non-iOS or older iOS — available without permission + setCompassAvailable(true); + setPermissionState("granted"); + }; + + checkPermission(); + }, []); + + const requestCompassPermission = async () => { + if (typeof (DeviceOrientationEvent as any).requestPermission === "function") { + try { + const result = await (DeviceOrientationEvent as any).requestPermission(); + setPermissionState(result === "granted" ? "granted" : "denied"); + if (result === "granted") { + startCompass(); + } + } catch { + setPermissionState("denied"); + } + } else { + setPermissionState("granted"); + startCompass(); + } + }; + + const startCompass = () => { + if (!window.DeviceOrientationEvent) return; + + const handler = (event: DeviceOrientationEvent) => { + // alpha = compass heading (0-360, 0 = North) + // webkitCompassHeading for iOS + let heading: number | null = null; + + if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) { + heading = event.webkitCompassHeading; + } else if (event.alpha !== null) { + // alpha is heading from North when absolute is true + heading = event.alpha; + } + + if (heading !== null) { + // Smooth the heading with a simple lerp + if (orientationRef.current !== null) { + let diff = heading - orientationRef.current; + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + orientationRef.current += diff * 0.3; // smoothing factor + } else { + orientationRef.current = heading; + } + setDeviceHeading(orientationRef.current); + setCompassAvailable(true); + } + }; + + window.addEventListener("deviceorientation", handler, true); + + return () => { + window.removeEventListener("deviceorientation", handler, true); + }; + }; + + useEffect(() => { + if (permissionState === "granted") { + const cleanup = startCompass(); + return () => { if (cleanup) cleanup(); }; + } + }, [permissionState]); + + // ── Computed values ── + const bearing = position ? qiblaBearing(position.lat, position.lng) : 0; + const distance = position ? distanceToMecca(position.lat, position.lng) : 0; + const cardinalDir = bearingToDirection(bearing); + const bearingStr = `${Math.round(bearing)}° ${cardinalDir}`; + + // ── Render ── + return ( +
+ {/* ── Header ── */} +
+
+

+ 🕋 Qibla Finder +

+

Find direction to the Kaaba

+
+
+ + +
+
+ + {/* ── Info Panel ── */} + {showInfo && ( +
+
+

+ How to use +

+
    +
  • Enable Location for your current position
  • +
  • Enable Motion/Orientation access for live compass
  • +
  • Rotate your device until the gold arrow points to the 🕋 Kaaba icon
  • +
  • The N marker shows True North
  • +
  • Without compass sensor, use the bearing angle manually
  • +
+

+ Kaaba: {KAABA_LAT}°N, {KAABA_LNG}°E +

+
+
+ )} + + {/* ── Compass Card ── */} +
+
+ {/* Decorative glow */} +
+
+ +
+ {/* Compass */} + {position && ( + + )} + + {/* Geolocating spinner */} + {geolocating && !position && ( +
+ +

Getting your location...

+
+ )} + + {/* Error state */} + {!geolocating && positionError && !position && ( +
+ +

{positionError}

+ +
+ )} + + {/* Bearing info */} + {position && ( + <> +
+

+ {bearingStr} +

+

+ from True North +

+
+ + {/* Distance + Location row */} +
+
+

{formatDistance(distance)}

+

to Kaaba

+
+
+
+

+ {position.lat.toFixed(4)}°, {position.lng.toFixed(4)}° +

+

+ ±{Math.round(position.accuracy)}m accuracy +

+
+
+
+ +

+ {position.lat >= 0 ? "N" : "S"}, {position.lng >= 0 ? "E" : "W"} +

+
+
+ + {/* Compass indicator */} +
+
+ + {compassAvailable && permissionState === "granted" ? "Live" : "No compass"} +
+
+ + {deviceHeading !== null ? `${Math.round(deviceHeading)}°` : "—"} +
+
+ + )} +
+
+
+ + {/* ── Compass Permission (iOS) ── */} + {permissionState === "prompt" && compassAvailable && ( +
+
+

+ Enable device orientation to see live compass direction: +

+ +
+
+ )} + + {/* ── Compass unavailable notice ── */} + {permissionState === "denied" && ( +
+
+ +

+ Compass access denied. Use the bearing angle ({bearingStr}) to find Qibla direction manually. +

+
+
+ )} + + {/* ── Loading overlay (auth) ── */} + {authLoading && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 8524519..0ea5a83 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -9,12 +9,14 @@ import { MessageCircle, Wallet, User, + Clock, } from "lucide-react"; const navItems = [ { href: "/nur", label: "Nur", icon: Bot }, { href: "/souq", label: "Souq", icon: ShoppingBag }, { href: "/", label: "Home", icon: null, isHome: true }, + { href: "/prayer", label: "Prayer", icon: Clock }, { href: "/forum", label: "Forum", icon: MessageCircle }, { href: "/wallet", label: "Wallet", icon: Wallet }, { href: "/profile", label: "Profile", icon: User }, diff --git a/src/lib/geo.ts b/src/lib/geo.ts new file mode 100644 index 0000000..664272b --- /dev/null +++ b/src/lib/geo.ts @@ -0,0 +1,44 @@ +/** + * Haversine distance utility. + * Computes the great-circle distance between two points on Earth + * using the haversine formula. + */ + +const EARTH_RADIUS_KM = 6371; + +/** + * Convert degrees to radians. + */ +function toRadians(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** + * Calculate the distance (in km) between two geographic coordinates + * using the haversine formula. + * + * @param lat1 Latitude of point 1 (degrees) + * @param lng1 Longitude of point 1 (degrees) + * @param lat2 Latitude of point 2 (degrees) + * @param lng2 Longitude of point 2 (degrees) + * @returns Distance in kilometres, rounded to 2 decimal places + */ +export function haversineDistance( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const dLat = toRadians(lat2 - lat1); + const dLng = toRadians(lng2 - lng1); + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLng / 2) ** 2; + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return Math.round(EARTH_RADIUS_KM * c * 100) / 100; +} diff --git a/src/lib/qibla.ts b/src/lib/qibla.ts new file mode 100644 index 0000000..64632e8 --- /dev/null +++ b/src/lib/qibla.ts @@ -0,0 +1,96 @@ +/** + * Qibla direction calculation utilities. + * + * Computes the bearing (direction) from a user's location to the Kaaba in Mecca. + * Also provides distance to Mecca using the haversine formula. + * + * Kaaba coordinates: 21.4225° N, 39.8262° E + */ + +const KAABA_LAT = 21.4225; +const KAABA_LNG = 39.8262; + +/** + * Convert degrees to radians. + */ +function toRadians(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** + * Convert radians to degrees. + */ +function toDegrees(rad: number): number { + return (rad * 180) / Math.PI; +} + +/** + * Calculate the bearing (direction) from a location to the Kaaba in Mecca. + * + * Uses the standard great-circle bearing formula: + * θ = atan2(sin(Δλ)·cos(φ₂), cos(φ₁)·sin(φ₂) − sin(φ₁)·cos(φ₂)·cos(Δλ)) + * + * @param lat User's latitude (degrees) + * @param lng User's longitude (degrees) + * @returns Bearing in degrees clockwise from North (0–360) + */ +export function qiblaBearing(lat: number, lng: number): number { + const φ1 = toRadians(lat); + const φ2 = toRadians(KAABA_LAT); + const Δλ = toRadians(KAABA_LNG - lng); + + const y = Math.sin(Δλ) * Math.cos(φ2); + const x = + Math.cos(φ1) * Math.sin(φ2) - + Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); + + let bearing = toDegrees(Math.atan2(y, x)); + // Normalize to 0–360 + return (bearing + 360) % 360; +} + +/** + * Calculate the distance from a location to the Kaaba in Mecca. + * + * @param lat User's latitude (degrees) + * @param lng User's longitude (degrees) + * @returns Distance in kilometres, rounded to 1 decimal place + */ +export function distanceToMecca(lat: number, lng: number): number { + const EARTH_RADIUS_KM = 6371; + const dLat = toRadians(KAABA_LAT - lat); + const dLng = toRadians(KAABA_LNG - lng); + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRadians(lat)) * + Math.cos(toRadians(KAABA_LAT)) * + Math.sin(dLng / 2) ** 2; + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return Math.round(EARTH_RADIUS_KM * c * 10) / 10; +} + +/** + * Get a human-readable cardinal direction from a bearing. + * + * @param bearing Bearing in degrees (0–360) + * @returns Cardinal direction string (e.g., "NE") + */ +export function bearingToDirection(bearing: number): string { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + const index = Math.round(bearing / 45) % 8; + return dirs[index]; +} + +/** + * Format distance in a human-readable way. + */ +export function formatDistance(km: number): string { + if (km < 1) return `${Math.round(km * 1000)} m`; + if (km < 100) return `${km.toFixed(1)} km`; + return `${Math.round(km).toLocaleString()} km`; +} + +export { KAABA_LAT, KAABA_LNG };