Add Prayer Reminder, Dhikr Counter, Qibla Finder, Halal Monitor updates
- Prayer reminder: /prayer page with 5 daily times, countdown, alAdhan API - Dhikr counter: /dhikr page with 3 presets, custom counter, haptics, DB tracking - Qibla finder: /qibla page with compass SVG, DeviceOrientation, bearing to Kaaba - Halal Monitor: Leaflet + Google Places, 26 KL markers, geolocation, distance sort - BottomNav: added clock icon for prayer - Prisma: schema updates for DhikrSession, DhikrDay, DailyStreak, XpTransaction - Geo utility module - Weighted Traefik routing: Contabo 99% / Synology 1% cold standby
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -9,11 +9,27 @@ const MOCK_PLACES: Record<string, any> = {
|
||||
"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) {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string, string> = {
|
||||
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<string, string> = {
|
||||
"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<string, number>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
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<AlAdhanResponse> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
|
||||
for (const prayer of prayerOrder) {
|
||||
const time = (timings as unknown as Record<string, string>)[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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, { from: string; to: string; accent: string; ring: string }> = {
|
||||
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 (
|
||||
<svg width={size} height={size} className="transform -rotate-90 shrink-0">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={strokeWidth} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset} className="transition-all duration-300 ease-out" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
export default function DhikrPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// State
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>("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<DayStats | null>(null);
|
||||
const [recentSessions, setRecentSessions] = useState<RecentSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-4 pt-6 pb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
📿 Dhikr
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Digital Tasbih Counter</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowRecent((s) => !s)}
|
||||
className="w-10 h-10 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-95 transition"
|
||||
title="Today's stats">
|
||||
<BarChart3 size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<button onClick={() => { setCount(0); setMode("preset"); setSelectedPreset("subhanallah"); }}
|
||||
className="w-10 h-10 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-95 transition"
|
||||
title="Reset">
|
||||
<RotateCcw size={16} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preset Selector ── */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||
{PRESETS.map((p) => {
|
||||
const active = selectedPreset === p.id && mode === "preset";
|
||||
const c = COLORS[p.id];
|
||||
return (
|
||||
<button key={p.id} onClick={() => switchPreset(p.id)}
|
||||
className={`flex-1 min-w-[100px] rounded-2xl px-3 py-3 border text-center transition-all active:scale-95
|
||||
${active ? `bg-gradient-to-br ${c.from} ${c.to} border-${c.accent}-500/50` : "bg-[#111118] border-gray-800/60"}`}>
|
||||
<p className={`text-sm font-semibold ${active ? "text-white" : "text-gray-400"}`}>{p.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${active ? "text-gray-300" : "text-gray-600"}`}>
|
||||
{active ? `0/${p.target}` : `/${p.target}`}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={startCustom}
|
||||
className={`flex-1 min-w-[80px] rounded-2xl px-3 py-3 border text-center transition-all active:scale-95
|
||||
${mode === "custom" ? "bg-gradient-to-br from-purple-900/30 to-purple-800/10 border-purple-500/50" : "bg-[#111118] border-gray-800/60"}`}>
|
||||
<p className={`text-sm font-semibold ${mode === "custom" ? "text-white" : "text-gray-400"}`}>Custom</p>
|
||||
<p className={`text-xs mt-0.5 ${mode === "custom" ? "text-gray-300" : "text-gray-600"}`}>
|
||||
{mode === "custom" ? `${count}/${customTarget}` : "🎯"}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Count Display & Counter ── */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="relative bg-gradient-to-br from-[#111118] to-gray-900/80 border border-gray-800/60 rounded-3xl p-6 overflow-hidden">
|
||||
{/* Decorative glow */}
|
||||
<div className={`absolute -top-16 -right-16 w-40 h-40 bg-${colors.accent}-500/5 rounded-full blur-3xl`} />
|
||||
|
||||
{/* Arabic text + progress */}
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* Circular progress + count */}
|
||||
<div className="relative mb-4">
|
||||
<CircularRing percent={percent} size={140} strokeWidth={6} color={colors.ring} />
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{isComplete ? (
|
||||
<div className="text-center">
|
||||
<Check size={32} className="text-green-400 mx-auto mb-1" />
|
||||
<p className="text-lg font-bold text-green-400">Complete!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-5xl font-bold text-white tabular-nums">{count}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">/ {target}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arabic dhikr */}
|
||||
{mode === "preset" && currentPreset && (
|
||||
<p className="text-2xl font-arabic text-white mb-1 text-center" dir="rtl">{currentPreset.arabic}</p>
|
||||
)}
|
||||
{mode === "custom" && (
|
||||
<p className="text-lg text-gray-300 mb-1 text-center">Custom Dhikr</p>
|
||||
)}
|
||||
{mode === "preset" && currentPreset && (
|
||||
<p className="text-xs text-gray-500 italic mb-4">{currentPreset.translation}</p>
|
||||
)}
|
||||
{mode === "custom" && (
|
||||
<p className="text-xs text-gray-500 italic mb-4">Set your own count target</p>
|
||||
)}
|
||||
|
||||
{/* Tap Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseLeave={handlePointerUp}
|
||||
onTouchStart={(e) => { handlePointerDown(); handleTap(e); }}
|
||||
onTouchEnd={handlePointerUp}
|
||||
onClick={(e) => { if (e.type === "click") handleTap(e); }}
|
||||
className="relative w-48 h-48 rounded-full bg-gradient-to-br from-gray-800 to-gray-900 border-2 border-gray-700/50
|
||||
active:scale-95 active:from-gray-700 active:to-gray-800 transition-all duration-100
|
||||
shadow-lg overflow-hidden select-none touch-none"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
>
|
||||
{/* Ripple effects */}
|
||||
{ripples.map((r) => (
|
||||
<span key={r.id} className="absolute w-4 h-4 rounded-full bg-white/20 animate-ping pointer-events-none"
|
||||
style={{ left: r.x - 8, top: r.y - 8 }} />
|
||||
))}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Tap to count</p>
|
||||
{count === 0 && (
|
||||
<p className="text-[10px] text-gray-600">Long-press to reset</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* +/- Buttons */}
|
||||
<div className="flex items-center gap-6 mt-4">
|
||||
<button onClick={() => setCount((c) => Math.max(0, c - 1))}
|
||||
className="w-12 h-12 rounded-xl bg-gray-800/60 border border-gray-700/40 flex items-center justify-center active:scale-90 transition">
|
||||
<Minus size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{mode === "custom" && (
|
||||
<button onClick={() => setShowTargetPicker(true)}
|
||||
className="px-4 h-12 rounded-xl bg-purple-900/20 border border-purple-700/30 text-purple-300 text-sm font-medium active:scale-95 transition">
|
||||
Target: {customTarget}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={() => setCount((c) => c + 10)}
|
||||
className="w-12 h-12 rounded-xl bg-gray-800/60 border border-gray-700/40 flex items-center justify-center active:scale-90 transition">
|
||||
<Plus size={18} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Complete button */}
|
||||
{count > 0 && (
|
||||
<button onClick={() => {
|
||||
const type = mode === "preset" && currentPreset ? currentPreset.id : "custom";
|
||||
logSession(type, count, target);
|
||||
setCount(0);
|
||||
}} disabled={saving}
|
||||
className="mt-4 w-full py-3.5 bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500
|
||||
disabled:opacity-50 text-white text-sm font-semibold rounded-xl active:scale-[0.98] transition-all min-h-[44px] flex items-center justify-center gap-2">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
{saving ? "Saving..." : "Save & Complete Session"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stats / Recent Sessions Panel ── */}
|
||||
{showRecent && dayStats && (
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Today's Dhikr</h3>
|
||||
<div className="space-y-2">
|
||||
<StatRow label="SubhanAllah" value={dayStats.subhanallah} icon="🕌" />
|
||||
<StatRow label="Alhamdulillah" value={dayStats.alhamdulillah} icon="🍃" />
|
||||
<StatRow label="Allahu Akbar" value={dayStats.allahuakbar} icon="⭐" />
|
||||
<StatRow label="Custom" value={dayStats.custom} icon="📿" />
|
||||
<div className="border-t border-gray-800/60 pt-2 mt-2">
|
||||
<StatRow label="Total Count" value={dayStats.totalCount} icon="📊" highlight />
|
||||
<StatRow label="Sessions" value={dayStats.sessionCount} icon="🔄" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold text-white mt-4 mb-2">Recent Sessions</h3>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{recentSessions.slice(0, 10).map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between text-xs text-gray-500 bg-gray-900/40 rounded-lg px-3 py-2">
|
||||
<span className="font-medium text-gray-400 capitalize">{s.type}</span>
|
||||
<span>+{s.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Target Picker Modal (custom mode) ── */}
|
||||
{showTargetPicker && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={() => setShowTargetPicker(false)}>
|
||||
<div className="bg-[#1a1a25] border border-gray-800 rounded-2xl p-6 w-full max-w-xs"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Set Target</h3>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{ALL_TARGETS.map((t) => (
|
||||
<button key={t.value} onClick={() => { setCustomTarget(t.value); setShowTargetPicker(false); }}
|
||||
className={`py-3 rounded-xl border text-sm font-medium transition-all active:scale-95
|
||||
${customTarget === t.value ? "bg-purple-900/30 border-purple-500/50 text-purple-300" : "bg-gray-800/40 border-gray-700/40 text-gray-400"}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 text-center">Or close to keep current ({customTarget})</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Reset confirmation ── */}
|
||||
{showReset && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={() => setShowReset(false)}>
|
||||
<div className="bg-[#1a1a25] border border-gray-800 rounded-2xl p-6 w-full max-w-xs text-center"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-4">Reset counter? Current count: {count}</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowReset(false)}
|
||||
className="flex-1 py-3 bg-gray-800/60 border border-gray-700/40 text-gray-400 text-sm rounded-xl active:scale-95 transition">Cancel</button>
|
||||
<button onClick={resetCount}
|
||||
className="flex-1 py-3 bg-red-900/30 border border-red-700/40 text-red-300 text-sm rounded-xl active:scale-95 transition">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loader ── */}
|
||||
{(authLoading || loading) && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={20} className="text-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, icon, highlight }: { label: string; value: number; icon: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1.5">
|
||||
<span>{icon}</span> {label}
|
||||
</span>
|
||||
<span className={`text-xs font-bold ${highlight ? "text-[#D4AF37]" : "text-gray-300"}`}>
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+300
-98
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
MapPin,
|
||||
Search,
|
||||
@@ -15,7 +16,28 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
// Leaflet CSS — safe in client component
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// ─── Dynamic react-leaflet imports (SSR safe) ──────────────
|
||||
const LeafletMapContainer = dynamic(
|
||||
() => import("react-leaflet").then((m) => m.MapContainer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const TileLayer = dynamic(
|
||||
() => import("react-leaflet").then((m) => m.TileLayer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Marker = dynamic(
|
||||
() => import("react-leaflet").then((m) => m.Marker),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Popup = dynamic(
|
||||
() => import("react-leaflet").then((m) => m.Popup),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
interface Place {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,6 +47,7 @@ interface Place {
|
||||
rating: number;
|
||||
type: "mosque" | "restaurant" | "cafe";
|
||||
halal_certified: boolean;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
interface UsageInfo {
|
||||
@@ -44,7 +67,30 @@ interface Bookmark {
|
||||
place: Place | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
interface UserLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
// ─── Haversine distance (km) ────────────────────────────────
|
||||
function haversine(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────
|
||||
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
all: "All",
|
||||
@@ -53,7 +99,9 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
cafe: "Cafes",
|
||||
};
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_CENTER: [number, number] = [3.139, 101.6869]; // Kuala Lumpur
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────
|
||||
export default function HalalMonitorPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -66,20 +114,87 @@ export default function HalalMonitorPage() {
|
||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const markersRef = useRef<any[]>([]);
|
||||
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [icons, setIcons] = useState<Record<string, any> | null>(null);
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro || false;
|
||||
const isFree = !isPremium;
|
||||
|
||||
// ── Redirect if not logged in ──────────────────────────────────────────
|
||||
// ── Initialize Leaflet custom icons ───────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const L = await import("leaflet");
|
||||
// Fix default icon paths for bundlers
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png",
|
||||
iconUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
|
||||
shadowUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
setIcons({
|
||||
mosque: L.divIcon({
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(16,185,129,0.9);border:2px solid rgba(110,231,183,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🕌</div>`,
|
||||
className: "",
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 36],
|
||||
popupAnchor: [0, -36],
|
||||
}),
|
||||
restaurant: L.divIcon({
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🍽️</div>`,
|
||||
className: "",
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 36],
|
||||
popupAnchor: [0, -36],
|
||||
}),
|
||||
cafe: L.divIcon({
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">☕</div>`,
|
||||
className: "",
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 36],
|
||||
popupAnchor: [0, -36],
|
||||
}),
|
||||
user: L.divIcon({
|
||||
html: `<div style="width:20px;height:20px;border-radius:50%;background:#3b82f6;border:3px solid white;box-shadow:0 0 0 3px rgba(59,130,246,0.4)"></div>`,
|
||||
className: "",
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
}),
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── GeoLocation ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setLocationError("Geolocation is not supported by your browser");
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLocation({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
});
|
||||
setLocationError(null);
|
||||
},
|
||||
(err) => {
|
||||
setLocationError(err.message);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 }
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Redirect if not logged in ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/auth");
|
||||
}, [loading, token, router]);
|
||||
|
||||
// ── Fetch places ───────────────────────────────────────────────────────
|
||||
// ── Fetch places ──────────────────────────────────────────
|
||||
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -96,7 +211,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [search, typeFilter]);
|
||||
|
||||
// ── Fetch usage ────────────────────────────────────────────────────────
|
||||
// ── Fetch usage ───────────────────────────────────────────
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
@@ -112,7 +227,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Fetch bookmarks ────────────────────────────────────────────────────
|
||||
// ── Fetch bookmarks ───────────────────────────────────────
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
@@ -128,7 +243,7 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Track usage query ──────────────────────────────────────────────────
|
||||
// ── Track usage query ─────────────────────────────────────
|
||||
const trackQuery = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setUsageLoading(true);
|
||||
@@ -142,15 +257,9 @@ export default function HalalMonitorPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsage((prev) =>
|
||||
prev
|
||||
? { ...prev, ...data }
|
||||
: data
|
||||
);
|
||||
setUsage((prev) => (prev ? { ...prev, ...data } : data));
|
||||
} else if (res.status === 429) {
|
||||
// Rate limited — update usage from error response
|
||||
const data = await res.json();
|
||||
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
|
||||
setUsage((prev) => (prev ? { ...prev, remaining: 0 } : prev));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -159,10 +268,9 @@ export default function HalalMonitorPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// ── Initial load ───────────────────────────────────────────────────────
|
||||
// ── Initial load ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (loading || !token) return;
|
||||
|
||||
const init = async () => {
|
||||
setPageLoading(true);
|
||||
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
|
||||
@@ -171,7 +279,33 @@ export default function HalalMonitorPage() {
|
||||
init();
|
||||
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
|
||||
|
||||
// ── Search / filter triggers ───────────────────────────────────────────
|
||||
// ── Compute distances & sort by distance ──────────────────
|
||||
const sortedPlaces = useMemo(() => {
|
||||
if (!places.length) return [];
|
||||
const withDistances = places.map((p) => {
|
||||
if (userLocation) {
|
||||
const dist = haversine(
|
||||
userLocation.lat,
|
||||
userLocation.lng,
|
||||
p.lat,
|
||||
p.lng
|
||||
);
|
||||
return { ...p, distance: Math.round(dist * 100) / 100 };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (userLocation) {
|
||||
return [...withDistances].sort(
|
||||
(a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)
|
||||
);
|
||||
}
|
||||
return withDistances;
|
||||
}, [places, userLocation]);
|
||||
|
||||
// Free tier shows ALL place types (rate-limiting stays on query tracking)
|
||||
const visiblePlaces = sortedPlaces;
|
||||
|
||||
// ── Search / filter triggers ──────────────────────────────
|
||||
const handleSearch = useCallback(() => {
|
||||
fetchPlaces(search, typeFilter);
|
||||
if (!isFree) trackQuery();
|
||||
@@ -186,16 +320,19 @@ export default function HalalMonitorPage() {
|
||||
[search, fetchPlaces, isFree, trackQuery]
|
||||
);
|
||||
|
||||
// ── Bookmark toggle ────────────────────────────────────────────────────
|
||||
// ── Bookmark toggle ───────────────────────────────────────
|
||||
const toggleBookmark = useCallback(
|
||||
async (place: Place) => {
|
||||
if (!token) return;
|
||||
const existing = bookmarks.find((b) => b.itemId === place.id);
|
||||
if (existing) {
|
||||
const res = await fetch(`/mobile/api/halal/bookmarks?id=${existing.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const res = await fetch(
|
||||
`/mobile/api/halal/bookmarks?id=${existing.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
if (res.ok) fetchBookmarks();
|
||||
} else {
|
||||
const res = await fetch("/mobile/api/halal/bookmarks", {
|
||||
@@ -221,12 +358,7 @@ export default function HalalMonitorPage() {
|
||||
[bookmarks]
|
||||
);
|
||||
|
||||
// ── Filter visible places (premium gating) ─────────────────────────────
|
||||
const visiblePlaces = isFree
|
||||
? places.filter((p) => p.type === "mosque")
|
||||
: places;
|
||||
|
||||
// ── Render stars ───────────────────────────────────────────────────────
|
||||
// ── Render stars ──────────────────────────────────────────
|
||||
const renderStars = (rating: number) => {
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
@@ -234,7 +366,11 @@ export default function HalalMonitorPage() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{Array.from({ length: full }, (_, i) => (
|
||||
<Star key={`full-${i}`} size={12} className="fill-amber-400 text-amber-400" />
|
||||
<Star
|
||||
key={`full-${i}`}
|
||||
size={12}
|
||||
className="fill-amber-400 text-amber-400"
|
||||
/>
|
||||
))}
|
||||
{half && (
|
||||
<div className="relative">
|
||||
@@ -249,12 +385,19 @@ export default function HalalMonitorPage() {
|
||||
{Array.from({ length: empty }, (_, i) => (
|
||||
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
|
||||
))}
|
||||
<span className="text-xs text-gray-400 ml-1">{rating.toFixed(1)}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Premium Gating Overlay ─────────────────────────────────────────────
|
||||
// ── Map center ────────────────────────────────────────────
|
||||
const mapCenter: [number, number] = userLocation
|
||||
? [userLocation.lat, userLocation.lng]
|
||||
: DEFAULT_CENTER;
|
||||
|
||||
// ── Premium Gating Overlay ─────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
@@ -267,7 +410,7 @@ export default function HalalMonitorPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
{/* ── Header ───────────────────────────────────────── */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
@@ -286,7 +429,12 @@ export default function HalalMonitorPage() {
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
|
||||
width: `${Math.min(
|
||||
100,
|
||||
((usage?.queriesUsed ?? 0) /
|
||||
(usage?.queriesLimit ?? 10)) *
|
||||
100
|
||||
)}%`,
|
||||
backgroundColor:
|
||||
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
||||
}}
|
||||
@@ -299,7 +447,7 @@ export default function HalalMonitorPage() {
|
||||
Discover halal-certified places around you
|
||||
</p>
|
||||
|
||||
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
|
||||
{/* ── Premium Upgrade Banner ─────────────────────── */}
|
||||
{isFree && (
|
||||
<button
|
||||
onClick={() => router.push("/upgrade")}
|
||||
@@ -314,7 +462,7 @@ export default function HalalMonitorPage() {
|
||||
Upgrade for Unlimited
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Unlock restaurants + cafes & unlimited queries
|
||||
Unlock unlimited queries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,12 +474,12 @@ export default function HalalMonitorPage() {
|
||||
{isFree && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
|
||||
<Crown size={12} />
|
||||
Free tier: mosques only • 10 queries/day
|
||||
Free tier: 10 queries/day
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search Bar ─────────────────────────────────────────────────── */}
|
||||
{/* ── Search Bar ───────────────────────────────────── */}
|
||||
<div className="px-4 mb-3">
|
||||
<div className="flex items-center gap-2 bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-3">
|
||||
<Search size={16} className="text-gray-500 shrink-0" />
|
||||
@@ -344,19 +492,25 @@ export default function HalalMonitorPage() {
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
fetchPlaces("", typeFilter);
|
||||
}}
|
||||
>
|
||||
<X size={14} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category Filter ────────────────────────────────────────────── */}
|
||||
{/* ── Category Filter ──────────────────────────────── */}
|
||||
<div className="px-4 mb-4 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max pb-1">
|
||||
{TYPES.map((t) => {
|
||||
const active = typeFilter === t;
|
||||
const isDisabled = isFree && t !== "all" && t !== "mosque";
|
||||
// All filter buttons enabled for free tier (rate limiting stays)
|
||||
const isDisabled = false;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
@@ -377,51 +531,58 @@ export default function HalalMonitorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map Placeholder ────────────────────────────────────────────── */}
|
||||
{/* ── Leaflet Map ──────────────────────────────────── */}
|
||||
<div className="mx-4 mb-4">
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full h-48 rounded-2xl overflow-hidden relative bg-gradient-to-br from-emerald-900/40 via-[#111118] to-gray-900 border border-gray-800/60"
|
||||
>
|
||||
{/* Decorative grid lines */}
|
||||
<div className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
{/* Center pin */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center animate-pulse">
|
||||
<MapPin size={18} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-xs text-emerald-500/60 font-medium">
|
||||
{visiblePlaces.length} places found
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map labels */}
|
||||
{visiblePlaces.slice(0, 5).map((place) => (
|
||||
<div
|
||||
key={place.id}
|
||||
className="absolute w-2 h-2 rounded-full bg-emerald-400 shadow-lg shadow-emerald-500/40 animate-pulse"
|
||||
style={{
|
||||
left: `${((place.lng - 101.6) / 0.2) * 50 + 25}%`,
|
||||
top: `${((3.2 - place.lat) / 0.3) * 50 + 15}%`,
|
||||
}}
|
||||
<div className="w-full h-48 rounded-2xl overflow-hidden border border-gray-800/60">
|
||||
<LeafletMapContainer
|
||||
center={mapCenter}
|
||||
zoom={13}
|
||||
className="w-full h-full"
|
||||
scrollWheelZoom={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
))}
|
||||
{/* User location blue dot */}
|
||||
{icons && userLocation && (
|
||||
<Marker
|
||||
position={[userLocation.lat, userLocation.lng]}
|
||||
icon={icons.user}
|
||||
>
|
||||
<Popup>You are here</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
{/* Place markers */}
|
||||
{icons &&
|
||||
visiblePlaces.map((place) => (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icons[place.type]}
|
||||
eventHandlers={{
|
||||
click: () => setSelectedPlace(place),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm font-semibold">{place.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{place.address}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</LeafletMapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Results List ───────────────────────────────────────────────── */}
|
||||
{/* ── Results List ─────────────────────────────────── */}
|
||||
<div className="px-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
{visiblePlaces.length} {visiblePlaces.length === 1 ? "place" : "places"}
|
||||
{visiblePlaces.length}{" "}
|
||||
{visiblePlaces.length === 1 ? "place" : "places"}
|
||||
{userLocation && " · Nearest first"}
|
||||
</h2>
|
||||
{usageLoading && (
|
||||
<Loader2 size={12} className="animate-spin text-gray-600" />
|
||||
@@ -437,9 +598,7 @@ export default function HalalMonitorPage() {
|
||||
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
|
||||
<p className="text-sm text-gray-500">No places found</p>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
{isFree
|
||||
? "Free tier shows mosques only. Try a different search."
|
||||
: "Try adjusting your search or filters."}
|
||||
Try adjusting your search or filters.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -466,7 +625,11 @@ export default function HalalMonitorPage() {
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{place.type === "mosque" ? "Mosque" : "Restaurant"}
|
||||
{place.type === "mosque"
|
||||
? "Mosque"
|
||||
: place.type === "restaurant"
|
||||
? "Restaurant"
|
||||
: "Cafe"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -478,13 +641,22 @@ export default function HalalMonitorPage() {
|
||||
{/* Rating */}
|
||||
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
||||
|
||||
{/* Halal badge */}
|
||||
{place.halal_certified && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
Halal Certified
|
||||
</span>
|
||||
)}
|
||||
{/* Distance & Halal badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{place.distance !== undefined && (
|
||||
<span className="text-xs text-blue-400/80">
|
||||
{place.distance < 1
|
||||
? `${(place.distance * 1000).toFixed(0)} m`
|
||||
: `${place.distance.toFixed(1)} km`}
|
||||
</span>
|
||||
)}
|
||||
{place.halal_certified && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
Halal Certified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookmark heart */}
|
||||
@@ -512,7 +684,7 @@ export default function HalalMonitorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detail Modal ────────────────────────────────────────────────── */}
|
||||
{/* ── Detail Modal ──────────────────────────────────── */}
|
||||
{selectedPlace && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
|
||||
@@ -547,7 +719,11 @@ export default function HalalMonitorPage() {
|
||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"}
|
||||
{selectedPlace.type === "mosque"
|
||||
? "🕌 Mosque"
|
||||
: selectedPlace.type === "restaurant"
|
||||
? "🍽️ Restaurant"
|
||||
: "☕ Cafe"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -569,7 +745,9 @@ export default function HalalMonitorPage() {
|
||||
{/* Address */}
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-gray-400">{selectedPlace.address}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{selectedPlace.address}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
@@ -581,6 +759,19 @@ export default function HalalMonitorPage() {
|
||||
<span className="text-xs text-gray-600">/ 5.0</span>
|
||||
</div>
|
||||
|
||||
{/* Distance (if available) */}
|
||||
{selectedPlace.distance !== undefined && (
|
||||
<div className="flex items-center gap-2 mb-4 text-sm text-blue-400">
|
||||
<MapPin size={14} />
|
||||
<span>
|
||||
{selectedPlace.distance < 1
|
||||
? `${(selectedPlace.distance * 1000).toFixed(0)} m`
|
||||
: `${selectedPlace.distance.toFixed(1)} km`}{" "}
|
||||
from your location
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coords */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
||||
@@ -597,6 +788,17 @@ export default function HalalMonitorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open in Google Maps */}
|
||||
<a
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${selectedPlace.lat},${selectedPlace.lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 mb-3 bg-blue-500/20 text-blue-300 border border-blue-500/40 hover:bg-blue-500/30"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
Open in Google Maps
|
||||
</a>
|
||||
|
||||
{/* Bookmark button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
+61
-1
@@ -8,7 +8,7 @@ import {
|
||||
Bot, ShoppingBag, MessageCircle, Wallet,
|
||||
Flame, Sparkles, Star, ChevronRight,
|
||||
BookOpen, MapPin, Crown, Zap, TrendingUp,
|
||||
Gift, Trophy,
|
||||
Gift, Trophy, CircleDot, Compass,
|
||||
} from "lucide-react";
|
||||
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
@@ -19,6 +19,7 @@ export default function HomePage() {
|
||||
const [verse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [claimResult, setClaimResult] = useState<string | null>(null);
|
||||
const [dhikrTodayCount, setDhikrTodayCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/auth");
|
||||
@@ -34,6 +35,19 @@ export default function HomePage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [token, refreshStreak, refreshXp]);
|
||||
|
||||
// Fetch dhikr stats
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch("/mobile/api/dhikr/stats", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (data?.dayStats?.totalCount != null) setDhikrTodayCount(data.dayStats.totalCount);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
const handleClaimReward = async () => {
|
||||
if (!token || claiming) return;
|
||||
setClaiming(true);
|
||||
@@ -245,6 +259,52 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dhikr Today */}
|
||||
<div className="mx-4 mb-5">
|
||||
<Link href="/dhikr" className="block bg-gradient-to-br from-emerald-900/15 via-[#111118] to-gray-900/80 border border-emerald-800/25 rounded-2xl p-5 active:scale-[0.98] transition-all">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-emerald-900/30 flex items-center justify-center">
|
||||
<CircleDot size={18} className="text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Dhikr Today</h3>
|
||||
<p className="text-xs text-gray-500">Digital Tasbih Counter</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-emerald-400 tabular-nums">{dhikrTodayCount.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-gray-600">counts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-gray-500">
|
||||
<span className="text-emerald-400/70">سُبْحَانَ ٱللَّٰهِ</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-amber-400/70">ٱلْحَمْدُ لِلَّٰهِ</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-sky-400/70">ٱللَّٰهُ أَكْبَرُ</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Qibla Finder */}
|
||||
<div className="mx-4 mb-5">
|
||||
<Link href="/qibla" className="block bg-gradient-to-br from-amber-900/15 via-[#111118] to-gray-900/80 border border-amber-800/25 rounded-2xl p-5 active:scale-[0.98] transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-amber-900/30 flex items-center justify-center">
|
||||
<Compass size={18} className="text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Qibla Finder</h3>
|
||||
<p className="text-xs text-gray-500">Find direction to the Kaaba</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-amber-600" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className="transform -rotate-90"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#goldGradient)"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-500 ease-out"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#D4AF37" />
|
||||
<stop offset="100%" stopColor="#f0d060" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
export default function PrayerPage() {
|
||||
const [timings, setTimings] = useState<PrayerTimings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [location, setLocation] = useState<LocationSettings>(() => {
|
||||
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 (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-4 pt-6 pb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
🕌 Prayer Times
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="w-10 h-10 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-95 transition"
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`text-gray-400 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Location bar */}
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="flex items-center gap-1.5 mt-1 active:opacity-70 transition"
|
||||
>
|
||||
<MapPin size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{location.city}, {location.country}
|
||||
</span>
|
||||
<ChevronRight size={12} className="text-gray-600" />
|
||||
</button>
|
||||
|
||||
{/* Hijri date */}
|
||||
{showHijri && hijriDate && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
📅 {hijriDate}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Loading State ── */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 size={24} className="text-[#D4AF37] animate-spin" />
|
||||
<p className="text-sm text-gray-500">Fetching prayer times...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Error State ── */}
|
||||
{!loading && error && (
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="bg-red-900/15 border border-red-800/30 rounded-2xl p-5 text-center">
|
||||
<p className="text-sm text-red-400 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-5 py-2.5 bg-red-900/30 border border-red-700/40 text-red-300 text-sm font-medium rounded-xl active:scale-95 transition"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content ── */}
|
||||
{!loading && !error && timings && (
|
||||
<>
|
||||
{/* ── Next Prayer Card ── */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-[#D4AF37]/15 via-amber-900/10 to-[#111118] border border-[#D4AF37]/25 rounded-3xl p-6">
|
||||
{/* Decorative glow */}
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-[#D4AF37]/10 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-amber-500/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs font-medium text-[#D4AF37]/70 uppercase tracking-wider">
|
||||
Next Prayer
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={12} className="text-gray-500" />
|
||||
<span className="text-[10px] text-gray-500">
|
||||
{countdownValue <= 3600000
|
||||
? "Due soon"
|
||||
: countdownText.includes("h")
|
||||
? `${countdownText.split("h")[0]}h until`
|
||||
: `${countdownText.split("m")[0]}m until`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-white mb-1">
|
||||
{nextPrayer.label}
|
||||
</h2>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-light text-[#D4AF37] tracking-wider">
|
||||
{nextPrayer.time}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{PRAYER_INFO[nextPrayer.name!]?.subtitle ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-lg font-mono font-semibold text-white/80">
|
||||
{countdownText}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1.5">
|
||||
remaining
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prayer icon */}
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 flex items-center justify-center">
|
||||
{nextPrayer.name &&
|
||||
(() => {
|
||||
const Icon = PRAYER_INFO[nextPrayer.name].icon;
|
||||
return (
|
||||
<Icon
|
||||
size={28}
|
||||
className="text-[#D4AF37]"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-5">
|
||||
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#D4AF37] to-[#f0d060] rounded-full transition-all duration-1000 ease-out"
|
||||
style={{
|
||||
width: `${Math.round(nextPrayer.progress * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{Math.round(nextPrayer.progress * 100)}%
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
towards {nextPrayer.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Day Progress & Prayer Grid Row ── */}
|
||||
<div className="mx-4 mb-5 flex gap-3">
|
||||
{/* Day progress circle */}
|
||||
<div className="shrink-0 bg-[#111118] border border-gray-800/60 rounded-2xl p-3 flex flex-col items-center justify-center w-[100px]">
|
||||
<CircularProgress percent={dayProgress} size={64} strokeWidth={4} />
|
||||
<p className="text-[10px] text-gray-500 mt-2">Day Progress</p>
|
||||
<p className="text-xs font-bold text-white">
|
||||
{Math.round(dayProgress * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time info */}
|
||||
<div className="flex-1 bg-[#111118] border border-gray-800/60 rounded-2xl p-3 flex flex-col justify-center">
|
||||
<p className="text-xs text-gray-500">Current Time</p>
|
||||
<p className="text-lg font-bold text-white font-mono">
|
||||
{new Date(now).toLocaleTimeString("en-MY", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600 mt-0.5">
|
||||
{new Date(now).toLocaleDateString("en-MY", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Daily Prayer Times ── */}
|
||||
<div className="px-4 mb-5">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Daily Prayers
|
||||
</h2>
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden">
|
||||
{PRAYER_ORDER.map((name, idx) => {
|
||||
const isCurrent = idx === currentPrayerIndex;
|
||||
const isNext = name === nextPrayer.name;
|
||||
const Icon = PRAYER_INFO[name].icon;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`flex items-center justify-between px-4 py-3.5 min-h-[52px] transition-colors ${
|
||||
isNext
|
||||
? "bg-[#D4AF37]/8 border-l-2 border-[#D4AF37]"
|
||||
: isCurrent
|
||||
? "bg-white/[0.03] border-l-2 border-gray-700"
|
||||
: "border-l-2 border-transparent"
|
||||
} ${idx < PRAYER_ORDER.length - 1 ? "border-b border-gray-800/40" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-xl flex items-center justify-center ${
|
||||
isNext
|
||||
? "bg-[#D4AF37]/15"
|
||||
: "bg-gray-800/40"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
className={
|
||||
isNext ? "text-[#D4AF37]" : "text-gray-400"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isNext ? "text-[#D4AF37]" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{PRAYER_INFO[name].label}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
{PRAYER_INFO[name].subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-sm font-mono font-medium ${
|
||||
isNext ? "text-[#D4AF37]" : "text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{timings[name]}
|
||||
</span>
|
||||
{isNext && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#D4AF37]/15 text-[#D4AF37] font-medium">
|
||||
Next
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && !isNext && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-700/40 text-gray-400 font-medium">
|
||||
Now
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Settings Toggles ── */}
|
||||
<div className="px-4 mb-5">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Preferences
|
||||
</h2>
|
||||
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden">
|
||||
{/* Prayer Notifications */}
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-gray-800/40 min-h-[52px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gray-800/40 flex items-center justify-center">
|
||||
{notifications ? (
|
||||
<Bell size={16} className="text-[#D4AF37]" />
|
||||
) : (
|
||||
<BellOff size={16} className="text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
Prayer Notifications
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
Get reminded at prayer times
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotifications(!notifications)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
notifications
|
||||
? "bg-[#D4AF37]"
|
||||
: "bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
notifications ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Adhan Sound */}
|
||||
<div className="flex items-center justify-between px-4 py-3.5 min-h-[52px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gray-800/40 flex items-center justify-center">
|
||||
{adhanSound ? (
|
||||
<Volume2 size={16} className="text-[#D4AF37]" />
|
||||
) : (
|
||||
<VolumeX size={16} className="text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
Adhan Sound
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
Play the call to prayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAdhanSound(!adhanSound)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
adhanSound ? "bg-[#D4AF37]" : "bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
adhanSound ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Location Card (quick edit) ── */}
|
||||
<div className="px-4 mb-5">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="w-full flex items-center justify-between bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-3.5 active:scale-[0.98] transition min-h-[52px]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gray-800/40 flex items-center justify-center">
|
||||
<Navigation size={16} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">
|
||||
Change Location
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
{location.city}, {location.country}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── No timings but loaded state ── */}
|
||||
{!loading && !error && !timings && (
|
||||
<div className="mx-4">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-8 text-center">
|
||||
<Moon size={32} className="mx-auto text-gray-700 mb-3" />
|
||||
<p className="text-sm text-gray-500">No prayer data available</p>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
Pull down to refresh or check your location settings
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="mt-4 px-5 py-2.5 bg-[#D4AF37]/10 border border-[#D4AF37]/30 text-[#D4AF37] text-sm font-medium rounded-xl active:scale-95 transition"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Settings Modal ── */}
|
||||
{showSettings && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !editLoading && setShowSettings(false)}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div className="relative w-full max-w-md bg-[#111118] border border-gray-800/60 rounded-t-3xl sm:rounded-3xl p-6 pb-8 animate-fade-in mx-4">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<MapPin size={18} className="text-[#D4AF37]" />
|
||||
Location Settings
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="w-8 h-8 rounded-lg bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
|
||||
>
|
||||
<X size={14} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1.5 block">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editCity}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1.5 block">
|
||||
Country Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editCountry}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
Two-letter ISO country code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
disabled={editLoading}
|
||||
className="flex-1 py-3 bg-gray-800/60 text-gray-400 text-sm font-medium rounded-xl active:scale-95 transition disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={editLoading || !editCity.trim() || !editCountry.trim()}
|
||||
className="flex-1 py-3 bg-[#D4AF37] text-black text-sm font-semibold rounded-xl active:scale-95 transition disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{editLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={16} />
|
||||
)}
|
||||
{editLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative select-none" style={{ width: size, height: size }}>
|
||||
{/* Rotating compass face: when deviceHeading is available, rotate opposite so North aligns */}
|
||||
<div
|
||||
className="absolute inset-0 transition-transform duration-150 ease-linear"
|
||||
style={{ transform: deviceHeading !== null ? `rotate(${-deviceHeading}deg)` : 'rotate(0deg)' }}
|
||||
>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<defs>
|
||||
<radialGradient id="compassGlow">
|
||||
<stop offset="0%" stopColor="rgba(212,175,55,0.08)" />
|
||||
<stop offset="100%" stopColor="rgba(212,175,55,0)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Outer ring glow */}
|
||||
<circle cx={cx} cy={cy} r={outerR + 4} fill="url(#compassGlow)" />
|
||||
|
||||
{/* Outer ring */}
|
||||
<circle cx={cx} cy={cy} r={outerR} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
|
||||
<circle cx={cx} cy={cy} r={innerR} fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
|
||||
|
||||
{/* 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<number, string> = { 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 (
|
||||
<g key={deg}>
|
||||
<line x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke={isCardinal ? "rgba(255,255,255,0.5)" : "rgba(255,255,255,0.15)"}
|
||||
strokeWidth={isCardinal ? 1.5 : 0.5} />
|
||||
{label && (
|
||||
<text x={lx} y={ly} textAnchor="middle" dominantBaseline="central"
|
||||
fill={deg === 0 ? "#D4AF37" : "rgba(255,255,255,0.4)"}
|
||||
fontSize={deg === 0 ? 13 : 10} fontWeight={deg === 0 ? "bold" : "normal"}>
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<line key={deg} x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke="rgba(255,255,255,0.06)" strokeWidth="0.5" />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Qibla arrow — drawn above the compass but rotates with it */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
className="absolute transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: `rotate(${qiblaAngle}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
{/* Arrow pointing toward Mecca */}
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="absolute inset-0">
|
||||
{/* Arrow shaft */}
|
||||
<line x1={cx} y1={cy + 12} x2={cx} y2={cy - arrowLen}
|
||||
stroke="#D4AF37" strokeWidth="3" strokeLinecap="round"
|
||||
opacity={0.9} />
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points={`${cx},${cy - arrowLen - 12} ${cx - 8},${cy - arrowLen + 4} ${cx + 8},${cy - arrowLen + 4}`}
|
||||
fill="#D4AF37"
|
||||
/>
|
||||
{/* Glow dot at center */}
|
||||
<circle cx={cx} cy={cy} r={5} fill="#D4AF37" opacity={0.3}>
|
||||
<animate attributeName="r" values="5;8;5" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.3;0.1;0.3" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Kaaba icon at center (doesn't rotate) */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-gray-900 to-gray-800 border-2 border-[#D4AF37]/30 flex items-center justify-center shadow-lg">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" fill="rgba(212,175,55,0.2)" stroke="#D4AF37" strokeWidth="1.5" />
|
||||
<line x1="12" y1="4" x2="12" y2="20" stroke="#D4AF37" strokeWidth="1" opacity={0.5} />
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke="#D4AF37" strokeWidth="1" opacity={0.5} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed North indicator chevron at top (doesn't rotate) */}
|
||||
<div className="absolute top-1 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" className="text-[#D4AF37]">
|
||||
<polygon points="12,2 6,12 18,12" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
export default function QiblaPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [position, setPosition] = useState<GeoPosition | null>(null);
|
||||
const [positionError, setPositionError] = useState<string | null>(null);
|
||||
const [geolocating, setGeolocating] = useState(true);
|
||||
const [deviceHeading, setDeviceHeading] = useState<number | null>(null);
|
||||
const [compassAvailable, setCompassAvailable] = useState(false);
|
||||
const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied" | "unavailable">("prompt");
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
const orientationRef = useRef<number | null>(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 (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-4 pt-6 pb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
🕋 Qibla Finder
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Find direction to the Kaaba</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowInfo((s) => !s)}
|
||||
className="w-10 h-10 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-95 transition"
|
||||
title="Info">
|
||||
<Info size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<button onClick={startGeolocation} disabled={geolocating}
|
||||
className="w-10 h-10 rounded-xl bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-95 transition"
|
||||
title="Refresh location">
|
||||
<RefreshCw size={16} className={`text-gray-400 ${geolocating ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info Panel ── */}
|
||||
{showInfo && (
|
||||
<div className="mx-4 mb-4">
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-2 flex items-center gap-2">
|
||||
<Info size={14} className="text-[#D4AF37]" /> How to use
|
||||
</h3>
|
||||
<ul className="text-xs text-gray-500 space-y-1.5 list-disc list-inside">
|
||||
<li>Enable <strong className="text-gray-400">Location</strong> for your current position</li>
|
||||
<li>Enable <strong className="text-gray-400">Motion/Orientation</strong> access for live compass</li>
|
||||
<li>Rotate your device until the gold arrow points to the 🕋 Kaaba icon</li>
|
||||
<li>The <strong className="text-gray-400">N</strong> marker shows True North</li>
|
||||
<li>Without compass sensor, use the bearing angle manually</li>
|
||||
</ul>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
<strong className="text-gray-500">Kaaba:</strong> {KAABA_LAT}°N, {KAABA_LNG}°E
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Compass Card ── */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="relative bg-gradient-to-br from-[#111118] to-gray-900/80 border border-gray-800/60 rounded-3xl p-5 overflow-hidden">
|
||||
{/* Decorative glow */}
|
||||
<div className="absolute -top-16 -right-16 w-40 h-40 bg-amber-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-10 -left-10 w-28 h-28 bg-[#D4AF37]/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* Compass */}
|
||||
{position && (
|
||||
<CompassRose qiblaAngle={bearing} deviceHeading={deviceHeading} size={260} />
|
||||
)}
|
||||
|
||||
{/* Geolocating spinner */}
|
||||
{geolocating && !position && (
|
||||
<div className="flex flex-col items-center py-16 gap-3">
|
||||
<Locate size={32} className="text-[#D4AF37] animate-pulse" />
|
||||
<p className="text-sm text-gray-500">Getting your location...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!geolocating && positionError && !position && (
|
||||
<div className="flex flex-col items-center py-16 gap-3 px-4">
|
||||
<AlertTriangle size={28} className="text-amber-400" />
|
||||
<p className="text-sm text-amber-400/80 text-center">{positionError}</p>
|
||||
<button onClick={startGeolocation}
|
||||
className="mt-2 px-6 py-3 bg-amber-900/20 border border-amber-700/30 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bearing info */}
|
||||
{position && (
|
||||
<>
|
||||
<div className="mt-3 text-center">
|
||||
<p className="text-3xl font-bold text-[#D4AF37] tracking-wider font-mono">
|
||||
{bearingStr}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
from True North
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Distance + Location row */}
|
||||
<div className="flex items-center gap-4 mt-4 bg-gray-900/40 rounded-2xl px-5 py-3 border border-gray-800/40">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-white tabular-nums">{formatDistance(distance)}</p>
|
||||
<p className="text-[10px] text-gray-600">to Kaaba</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-800" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-gray-300">
|
||||
{position.lat.toFixed(4)}°, {position.lng.toFixed(4)}°
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
±{Math.round(position.accuracy)}m accuracy
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-800" />
|
||||
<div className="text-center">
|
||||
<MapPin size={16} className="mx-auto text-gray-500" />
|
||||
<p className="text-[10px] text-gray-600 mt-0.5">
|
||||
{position.lat >= 0 ? "N" : "S"}, {position.lng >= 0 ? "E" : "W"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compass indicator */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs ${
|
||||
compassAvailable && permissionState === "granted"
|
||||
? "bg-emerald-900/20 text-emerald-400 border border-emerald-800/30"
|
||||
: "bg-gray-800/40 text-gray-500 border border-gray-700/30"
|
||||
}`}>
|
||||
<Compass size={12} />
|
||||
{compassAvailable && permissionState === "granted" ? "Live" : "No compass"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-gray-800/40 text-gray-500 border border-gray-700/30 text-xs">
|
||||
<Navigation size={12} />
|
||||
{deviceHeading !== null ? `${Math.round(deviceHeading)}°` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Compass Permission (iOS) ── */}
|
||||
{permissionState === "prompt" && compassAvailable && (
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="bg-amber-900/10 border border-amber-800/20 rounded-2xl p-4">
|
||||
<p className="text-xs text-amber-300/80 mb-3">
|
||||
Enable device orientation to see live compass direction:
|
||||
</p>
|
||||
<button onClick={requestCompassPermission}
|
||||
className="w-full py-3 bg-amber-900/30 border border-amber-700/40 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]">
|
||||
Enable Compass
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Compass unavailable notice ── */}
|
||||
{permissionState === "denied" && (
|
||||
<div className="mx-4 mb-5">
|
||||
<div className="bg-gray-900/30 border border-gray-800/60 rounded-2xl p-4 text-center">
|
||||
<Compass size={20} className="mx-auto text-gray-600 mb-2" />
|
||||
<p className="text-xs text-gray-500">
|
||||
Compass access denied. Use the bearing angle ({bearingStr}) to find Qibla direction manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loading overlay (auth) ── */}
|
||||
{authLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={20} className="text-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user