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[]
|
xpTransactions XpTransaction[]
|
||||||
achievements Achievement[]
|
achievements Achievement[]
|
||||||
userLevel UserLevel?
|
userLevel UserLevel?
|
||||||
|
dhikrSessions DhikrSession[]
|
||||||
|
dhikrDays DhikrDay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model DailyStreak {
|
model DailyStreak {
|
||||||
@@ -284,6 +286,35 @@ model HalalPlaceCache {
|
|||||||
expiresAt DateTime
|
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
|
// Add Relations to User model
|
||||||
/// NOTE: The Notification and Referral relations are declared on the models above.
|
/// 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")
|
/// 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-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-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-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-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-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-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-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-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) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { haversineDistance } from "@/lib/geo";
|
||||||
|
|
||||||
const MOCK_PLACES = [
|
interface Place {
|
||||||
// Mosques
|
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-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-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-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-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-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-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-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-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-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-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) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -21,27 +52,47 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const q = searchParams.get("q")?.toLowerCase();
|
const q = searchParams.get("q")?.toLowerCase();
|
||||||
const type = searchParams.get("type")?.toLowerCase();
|
const type = searchParams.get("type")?.toLowerCase();
|
||||||
|
const latParam = searchParams.get("lat");
|
||||||
|
const lngParam = searchParams.get("lng");
|
||||||
|
|
||||||
let filtered = [...MOCK_PLACES];
|
let filtered = [...MOCK_PLACES];
|
||||||
|
|
||||||
|
// Filter by type if provided and valid
|
||||||
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
|
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
|
||||||
filtered = filtered.filter((p) => p.type === type);
|
filtered = filtered.filter((p) => p.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by search query (name or address)
|
||||||
if (q) {
|
if (q) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.name.toLowerCase().includes(q) ||
|
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 });
|
return NextResponse.json({ places: filtered });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /api/halal/places error:", error);
|
console.error("GET /api/halal/places error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ 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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useAuth } from "@/lib/AuthContext";
|
import { useAuth } from "@/lib/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Search,
|
Search,
|
||||||
@@ -15,7 +16,28 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} 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 {
|
interface Place {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +47,7 @@ interface Place {
|
|||||||
rating: number;
|
rating: number;
|
||||||
type: "mosque" | "restaurant" | "cafe";
|
type: "mosque" | "restaurant" | "cafe";
|
||||||
halal_certified: boolean;
|
halal_certified: boolean;
|
||||||
|
distance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageInfo {
|
interface UsageInfo {
|
||||||
@@ -44,7 +67,30 @@ interface Bookmark {
|
|||||||
place: Place | null;
|
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 TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
all: "All",
|
all: "All",
|
||||||
@@ -53,7 +99,9 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
cafe: "Cafes",
|
cafe: "Cafes",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
const DEFAULT_CENTER: [number, number] = [3.139, 101.6869]; // Kuala Lumpur
|
||||||
|
|
||||||
|
// ─── Page ───────────────────────────────────────────────────
|
||||||
export default function HalalMonitorPage() {
|
export default function HalalMonitorPage() {
|
||||||
const { user, token, loading } = useAuth();
|
const { user, token, loading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -66,20 +114,87 @@ export default function HalalMonitorPage() {
|
|||||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [usageLoading, setUsageLoading] = useState(false);
|
const [usageLoading, setUsageLoading] = useState(false);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const [locationError, setLocationError] = useState<string | null>(null);
|
||||||
const mapInstanceRef = useRef<any>(null);
|
const [icons, setIcons] = useState<Record<string, any> | null>(null);
|
||||||
const markersRef = useRef<any[]>([]);
|
|
||||||
|
|
||||||
const isPremium = user?.isPremium || user?.isPro || false;
|
const isPremium = user?.isPremium || user?.isPro || false;
|
||||||
const isFree = !isPremium;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!loading && !token) router.push("/auth");
|
if (!loading && !token) router.push("/auth");
|
||||||
}, [loading, token, router]);
|
}, [loading, token, router]);
|
||||||
|
|
||||||
// ── Fetch places ───────────────────────────────────────────────────────
|
// ── Fetch places ──────────────────────────────────────────
|
||||||
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -96,7 +211,7 @@ export default function HalalMonitorPage() {
|
|||||||
}
|
}
|
||||||
}, [search, typeFilter]);
|
}, [search, typeFilter]);
|
||||||
|
|
||||||
// ── Fetch usage ────────────────────────────────────────────────────────
|
// ── Fetch usage ───────────────────────────────────────────
|
||||||
const fetchUsage = useCallback(async () => {
|
const fetchUsage = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -112,7 +227,7 @@ export default function HalalMonitorPage() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// ── Fetch bookmarks ────────────────────────────────────────────────────
|
// ── Fetch bookmarks ───────────────────────────────────────
|
||||||
const fetchBookmarks = useCallback(async () => {
|
const fetchBookmarks = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +243,7 @@ export default function HalalMonitorPage() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// ── Track usage query ──────────────────────────────────────────────────
|
// ── Track usage query ─────────────────────────────────────
|
||||||
const trackQuery = useCallback(async () => {
|
const trackQuery = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setUsageLoading(true);
|
setUsageLoading(true);
|
||||||
@@ -142,15 +257,9 @@ export default function HalalMonitorPage() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUsage((prev) =>
|
setUsage((prev) => (prev ? { ...prev, ...data } : data));
|
||||||
prev
|
|
||||||
? { ...prev, ...data }
|
|
||||||
: data
|
|
||||||
);
|
|
||||||
} else if (res.status === 429) {
|
} else if (res.status === 429) {
|
||||||
// Rate limited — update usage from error response
|
setUsage((prev) => (prev ? { ...prev, remaining: 0 } : prev));
|
||||||
const data = await res.json();
|
|
||||||
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -159,10 +268,9 @@ export default function HalalMonitorPage() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// ── Initial load ───────────────────────────────────────────────────────
|
// ── Initial load ──────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !token) return;
|
if (loading || !token) return;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setPageLoading(true);
|
setPageLoading(true);
|
||||||
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
|
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
|
||||||
@@ -171,7 +279,33 @@ export default function HalalMonitorPage() {
|
|||||||
init();
|
init();
|
||||||
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
|
}, [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(() => {
|
const handleSearch = useCallback(() => {
|
||||||
fetchPlaces(search, typeFilter);
|
fetchPlaces(search, typeFilter);
|
||||||
if (!isFree) trackQuery();
|
if (!isFree) trackQuery();
|
||||||
@@ -186,16 +320,19 @@ export default function HalalMonitorPage() {
|
|||||||
[search, fetchPlaces, isFree, trackQuery]
|
[search, fetchPlaces, isFree, trackQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Bookmark toggle ────────────────────────────────────────────────────
|
// ── Bookmark toggle ───────────────────────────────────────
|
||||||
const toggleBookmark = useCallback(
|
const toggleBookmark = useCallback(
|
||||||
async (place: Place) => {
|
async (place: Place) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const existing = bookmarks.find((b) => b.itemId === place.id);
|
const existing = bookmarks.find((b) => b.itemId === place.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const res = await fetch(`/mobile/api/halal/bookmarks?id=${existing.id}`, {
|
const res = await fetch(
|
||||||
method: "DELETE",
|
`/mobile/api/halal/bookmarks?id=${existing.id}`,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
{
|
||||||
});
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
if (res.ok) fetchBookmarks();
|
if (res.ok) fetchBookmarks();
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch("/mobile/api/halal/bookmarks", {
|
const res = await fetch("/mobile/api/halal/bookmarks", {
|
||||||
@@ -221,12 +358,7 @@ export default function HalalMonitorPage() {
|
|||||||
[bookmarks]
|
[bookmarks]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Filter visible places (premium gating) ─────────────────────────────
|
// ── Render stars ──────────────────────────────────────────
|
||||||
const visiblePlaces = isFree
|
|
||||||
? places.filter((p) => p.type === "mosque")
|
|
||||||
: places;
|
|
||||||
|
|
||||||
// ── Render stars ───────────────────────────────────────────────────────
|
|
||||||
const renderStars = (rating: number) => {
|
const renderStars = (rating: number) => {
|
||||||
const full = Math.floor(rating);
|
const full = Math.floor(rating);
|
||||||
const half = rating - full >= 0.5;
|
const half = rating - full >= 0.5;
|
||||||
@@ -234,7 +366,11 @@ export default function HalalMonitorPage() {
|
|||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
{Array.from({ length: full }, (_, i) => (
|
{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 && (
|
{half && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -249,12 +385,19 @@ export default function HalalMonitorPage() {
|
|||||||
{Array.from({ length: empty }, (_, i) => (
|
{Array.from({ length: empty }, (_, i) => (
|
||||||
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Premium Gating Overlay ─────────────────────────────────────────────
|
// ── Map center ────────────────────────────────────────────
|
||||||
|
const mapCenter: [number, number] = userLocation
|
||||||
|
? [userLocation.lat, userLocation.lng]
|
||||||
|
: DEFAULT_CENTER;
|
||||||
|
|
||||||
|
// ── Premium Gating Overlay ─────────────────────────────────
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||||
@@ -267,7 +410,7 @@ export default function HalalMonitorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
{/* ── Header ───────────────────────────────────────── */}
|
||||||
<div className="px-4 pt-6 pb-4">
|
<div className="px-4 pt-6 pb-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
@@ -286,7 +429,12 @@ export default function HalalMonitorPage() {
|
|||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500"
|
className="h-full rounded-full transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
|
width: `${Math.min(
|
||||||
|
100,
|
||||||
|
((usage?.queriesUsed ?? 0) /
|
||||||
|
(usage?.queriesLimit ?? 10)) *
|
||||||
|
100
|
||||||
|
)}%`,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
|
||||||
}}
|
}}
|
||||||
@@ -299,7 +447,7 @@ export default function HalalMonitorPage() {
|
|||||||
Discover halal-certified places around you
|
Discover halal-certified places around you
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
|
{/* ── Premium Upgrade Banner ─────────────────────── */}
|
||||||
{isFree && (
|
{isFree && (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/upgrade")}
|
onClick={() => router.push("/upgrade")}
|
||||||
@@ -314,7 +462,7 @@ export default function HalalMonitorPage() {
|
|||||||
Upgrade for Unlimited
|
Upgrade for Unlimited
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Unlock restaurants + cafes & unlimited queries
|
Unlock unlimited queries
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,12 +474,12 @@ export default function HalalMonitorPage() {
|
|||||||
{isFree && (
|
{isFree && (
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
|
||||||
<Crown size={12} />
|
<Crown size={12} />
|
||||||
Free tier: mosques only • 10 queries/day
|
Free tier: 10 queries/day
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Search Bar ─────────────────────────────────────────────────── */}
|
{/* ── Search Bar ───────────────────────────────────── */}
|
||||||
<div className="px-4 mb-3">
|
<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">
|
<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" />
|
<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"
|
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
fetchPlaces("", typeFilter);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<X size={14} className="text-gray-500" />
|
<X size={14} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Category Filter ────────────────────────────────────────────── */}
|
{/* ── Category Filter ──────────────────────────────── */}
|
||||||
<div className="px-4 mb-4 overflow-x-auto">
|
<div className="px-4 mb-4 overflow-x-auto">
|
||||||
<div className="flex gap-2 min-w-max pb-1">
|
<div className="flex gap-2 min-w-max pb-1">
|
||||||
{TYPES.map((t) => {
|
{TYPES.map((t) => {
|
||||||
const active = typeFilter === 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
@@ -377,51 +531,58 @@ export default function HalalMonitorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Map Placeholder ────────────────────────────────────────────── */}
|
{/* ── Leaflet Map ──────────────────────────────────── */}
|
||||||
<div className="mx-4 mb-4">
|
<div className="mx-4 mb-4">
|
||||||
<div
|
<div className="w-full h-48 rounded-2xl overflow-hidden border border-gray-800/60">
|
||||||
ref={mapRef}
|
<LeafletMapContainer
|
||||||
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"
|
center={mapCenter}
|
||||||
>
|
zoom={13}
|
||||||
{/* Decorative grid lines */}
|
className="w-full h-full"
|
||||||
<div className="absolute inset-0 opacity-10"
|
scrollWheelZoom={false}
|
||||||
style={{
|
>
|
||||||
backgroundImage:
|
<TileLayer
|
||||||
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
backgroundSize: "40px 40px",
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 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}%`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Results List ───────────────────────────────────────────────── */}
|
{/* ── Results List ─────────────────────────────────── */}
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<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>
|
</h2>
|
||||||
{usageLoading && (
|
{usageLoading && (
|
||||||
<Loader2 size={12} className="animate-spin text-gray-600" />
|
<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" />
|
<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-sm text-gray-500">No places found</p>
|
||||||
<p className="text-xs text-gray-700 mt-1">
|
<p className="text-xs text-gray-700 mt-1">
|
||||||
{isFree
|
Try adjusting your search or filters.
|
||||||
? "Free tier shows mosques only. Try a different search."
|
|
||||||
: "Try adjusting your search or filters."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -466,7 +625,11 @@ export default function HalalMonitorPage() {
|
|||||||
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
: "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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -478,13 +641,22 @@ export default function HalalMonitorPage() {
|
|||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
<div className="mb-1.5">{renderStars(place.rating)}</div>
|
||||||
|
|
||||||
{/* Halal badge */}
|
{/* Distance & Halal badge */}
|
||||||
{place.halal_certified && (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<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">
|
{place.distance !== undefined && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="text-xs text-blue-400/80">
|
||||||
Halal Certified
|
{place.distance < 1
|
||||||
</span>
|
? `${(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>
|
</div>
|
||||||
|
|
||||||
{/* Bookmark heart */}
|
{/* Bookmark heart */}
|
||||||
@@ -512,7 +684,7 @@ export default function HalalMonitorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Detail Modal ────────────────────────────────────────────────── */}
|
{/* ── Detail Modal ──────────────────────────────────── */}
|
||||||
{selectedPlace && (
|
{selectedPlace && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
|
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"
|
: "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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -569,7 +745,9 @@ export default function HalalMonitorPage() {
|
|||||||
{/* Address */}
|
{/* Address */}
|
||||||
<div className="flex items-start gap-2 mb-3">
|
<div className="flex items-start gap-2 mb-3">
|
||||||
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
@@ -581,6 +759,19 @@ export default function HalalMonitorPage() {
|
|||||||
<span className="text-xs text-gray-600">/ 5.0</span>
|
<span className="text-xs text-gray-600">/ 5.0</span>
|
||||||
</div>
|
</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 */}
|
{/* Coords */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
|
||||||
@@ -597,6 +788,17 @@ export default function HalalMonitorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Bookmark button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
+61
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
Bot, ShoppingBag, MessageCircle, Wallet,
|
Bot, ShoppingBag, MessageCircle, Wallet,
|
||||||
Flame, Sparkles, Star, ChevronRight,
|
Flame, Sparkles, Star, ChevronRight,
|
||||||
BookOpen, MapPin, Crown, Zap, TrendingUp,
|
BookOpen, MapPin, Crown, Zap, TrendingUp,
|
||||||
Gift, Trophy,
|
Gift, Trophy, CircleDot, Compass,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
|
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
|
||||||
import PremiumBadge from "@/components/PremiumBadge";
|
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 [verse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
|
||||||
const [claiming, setClaiming] = useState(false);
|
const [claiming, setClaiming] = useState(false);
|
||||||
const [claimResult, setClaimResult] = useState<string | null>(null);
|
const [claimResult, setClaimResult] = useState<string | null>(null);
|
||||||
|
const [dhikrTodayCount, setDhikrTodayCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !token) router.push("/auth");
|
if (!loading && !token) router.push("/auth");
|
||||||
@@ -34,6 +35,19 @@ export default function HomePage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [token, refreshStreak, refreshXp]);
|
}, [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 () => {
|
const handleClaimReward = async () => {
|
||||||
if (!token || claiming) return;
|
if (!token || claiming) return;
|
||||||
setClaiming(true);
|
setClaiming(true);
|
||||||
@@ -245,6 +259,52 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Stats Row */}
|
||||||
<div className="mx-4 mb-5">
|
<div className="mx-4 mb-5">
|
||||||
<div className="flex gap-3">
|
<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,
|
MessageCircle,
|
||||||
Wallet,
|
Wallet,
|
||||||
User,
|
User,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/nur", label: "Nur", icon: Bot },
|
{ href: "/nur", label: "Nur", icon: Bot },
|
||||||
{ href: "/souq", label: "Souq", icon: ShoppingBag },
|
{ href: "/souq", label: "Souq", icon: ShoppingBag },
|
||||||
{ href: "/", label: "Home", icon: null, isHome: true },
|
{ href: "/", label: "Home", icon: null, isHome: true },
|
||||||
|
{ href: "/prayer", label: "Prayer", icon: Clock },
|
||||||
{ href: "/forum", label: "Forum", icon: MessageCircle },
|
{ href: "/forum", label: "Forum", icon: MessageCircle },
|
||||||
{ href: "/wallet", label: "Wallet", icon: Wallet },
|
{ href: "/wallet", label: "Wallet", icon: Wallet },
|
||||||
{ href: "/profile", label: "Profile", icon: User },
|
{ 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