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:
root
2026-06-18 14:51:07 +02:00
parent ed34b186f9
commit 28be776c84
13 changed files with 2822 additions and 104 deletions
+31
View File
@@ -52,6 +52,8 @@ model User {
xpTransactions XpTransaction[]
achievements Achievement[]
userLevel UserLevel?
dhikrSessions DhikrSession[]
dhikrDays DhikrDay[]
}
model DailyStreak {
@@ -284,6 +286,35 @@ model HalalPlaceCache {
expiresAt DateTime
}
model DhikrSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type String // 'subhanallah' | 'alhamdulillah' | 'allahuakbar' | 'custom'
count Int @default(0)
target Int @default(33)
completed Boolean @default(true)
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
model DhikrDay {
userId String
user User @relation(fields: [userId], references: [id])
date String // YYYY-MM-DD
subhanallah Int @default(0)
alhamdulillah Int @default(0)
allahuakbar Int @default(0)
custom Int @default(0)
totalCount Int @default(0)
sessionCount Int @default(0)
updatedAt DateTime @updatedAt
@@id([userId, date])
@@index([userId])
}
// Add Relations to User model
/// NOTE: The Notification and Referral relations are declared on the models above.
/// User now implicitly has: notifications Notification[], referredReferrals Referral[] (via "Referrer"), referrerReferral Referral? (via "Referred")
+92
View File
@@ -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 });
}
+16
View File
@@ -9,11 +9,27 @@ const MOCK_PLACES: Record<string, any> = {
"mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
"mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
"mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
"mosque-6": { id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true },
"mosque-7": { id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true },
"mosque-8": { id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true },
"mosque-9": { id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true },
"mosque-10": { id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true },
"rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
"rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
"rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
"rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
"rest-6": { id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-7": { id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true },
"rest-8": { id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true },
"rest-9": { id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true },
"rest-10": { id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-11": { id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true },
"cafe-1": { id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true },
"cafe-2": { id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true },
"cafe-3": { id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true },
"cafe-4": { id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true },
"cafe-5": { id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true },
};
export async function GET(request: NextRequest) {
+56 -5
View File
@@ -1,19 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { haversineDistance } from "@/lib/geo";
const MOCK_PLACES = [
// Mosques
interface Place {
id: string;
name: string;
address: string;
lat: number;
lng: number;
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
distance?: number;
}
const MOCK_PLACES: Place[] = [
// ── Mosques ───────────────────────────────────────────
{ id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
{ id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
{ id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
{ id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
{ id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true },
{ id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true },
{ id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true },
{ id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true },
// Restaurants
// ── Restaurants ───────────────────────────────────────
{ id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
{ id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
{ id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
{ id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true },
{ id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true },
{ id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true },
// ── Cafes ─────────────────────────────────────────────
{ id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true },
{ id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true },
{ id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true },
{ id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true },
{ id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true },
];
export async function GET(request: NextRequest) {
@@ -21,27 +52,47 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase();
const type = searchParams.get("type")?.toLowerCase();
const latParam = searchParams.get("lat");
const lngParam = searchParams.get("lng");
let filtered = [...MOCK_PLACES];
// Filter by type if provided and valid
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
filtered = filtered.filter((p) => p.type === type);
}
// Filter by search query (name or address)
if (q) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address.toLowerCase().includes(q)
p.address.toLowerCase().includes(q),
);
}
// If lat/lng provided, calculate distance and sort by nearest
if (latParam && lngParam) {
const userLat = parseFloat(latParam);
const userLng = parseFloat(lngParam);
if (!isNaN(userLat) && !isNaN(userLng)) {
filtered = filtered.map((p) => ({
...p,
distance: haversineDistance(userLat, userLng, p.lat, p.lng),
}));
// Sort by distance ascending (nearest first)
filtered.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
}
}
return NextResponse.json({ places: filtered });
} catch (error) {
console.error("GET /api/halal/places error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
{ status: 500 },
);
}
}
+357
View File
@@ -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,
};
}
+465
View File
@@ -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
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import {
MapPin,
Search,
@@ -15,7 +16,28 @@ import {
Loader2,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────
// Leaflet CSS — safe in client component
import "leaflet/dist/leaflet.css";
// ─── Dynamic react-leaflet imports (SSR safe) ──────────────
const LeafletMapContainer = dynamic(
() => import("react-leaflet").then((m) => m.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import("react-leaflet").then((m) => m.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((m) => m.Popup),
{ ssr: false }
);
// ─── Types ──────────────────────────────────────────────────
interface Place {
id: string;
name: string;
@@ -25,6 +47,7 @@ interface Place {
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
distance?: number;
}
interface UsageInfo {
@@ -44,7 +67,30 @@ interface Bookmark {
place: Place | null;
}
// ─── Constants ────────────────────────────────────────────────────────────
interface UserLocation {
lat: number;
lng: number;
}
// ─── Haversine distance (km) ────────────────────────────────
function haversine(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// ─── Constants ──────────────────────────────────────────────
const TYPES = ["all", "mosque", "restaurant", "cafe"] as const;
const TYPE_LABELS: Record<string, string> = {
all: "All",
@@ -53,7 +99,9 @@ const TYPE_LABELS: Record<string, string> = {
cafe: "Cafes",
};
// ─── Page ─────────────────────────────────────────────────────────────────
const DEFAULT_CENTER: [number, number] = [3.139, 101.6869]; // Kuala Lumpur
// ─── Page ───────────────────────────────────────────────────
export default function HalalMonitorPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
@@ -66,20 +114,87 @@ export default function HalalMonitorPage() {
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
const [pageLoading, setPageLoading] = useState(true);
const [usageLoading, setUsageLoading] = useState(false);
const [mapReady, setMapReady] = useState(false);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
const [locationError, setLocationError] = useState<string | null>(null);
const [icons, setIcons] = useState<Record<string, any> | null>(null);
const isPremium = user?.isPremium || user?.isPro || false;
const isFree = !isPremium;
// ── Redirect if not logged in ──────────────────────────────────────────
// ── Initialize Leaflet custom icons ───────────────────────
useEffect(() => {
(async () => {
const L = await import("leaflet");
// Fix default icon paths for bundlers
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png",
iconUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
});
setIcons({
mosque: L.divIcon({
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(16,185,129,0.9);border:2px solid rgba(110,231,183,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🕌</div>`,
className: "",
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
}),
restaurant: L.divIcon({
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">🍽️</div>`,
className: "",
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
}),
cafe: L.divIcon({
html: `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:rgba(245,158,11,0.9);border:2px solid rgba(252,211,77,0.8);box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:18px">☕</div>`,
className: "",
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
}),
user: L.divIcon({
html: `<div style="width:20px;height:20px;border-radius:50%;background:#3b82f6;border:3px solid white;box-shadow:0 0 0 3px rgba(59,130,246,0.4)"></div>`,
className: "",
iconSize: [20, 20],
iconAnchor: [10, 10],
}),
});
})();
}, []);
// ── GeoLocation ───────────────────────────────────────────
useEffect(() => {
if (!navigator.geolocation) {
setLocationError("Geolocation is not supported by your browser");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setUserLocation({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
});
setLocationError(null);
},
(err) => {
setLocationError(err.message);
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 }
);
}, []);
// ── Redirect if not logged in ─────────────────────────────
useEffect(() => {
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
// ── Fetch places ───────────────────────────────────────────────────────
// ── Fetch places ──────────────────────────────────────────
const fetchPlaces = useCallback(async (q = search, type = typeFilter) => {
try {
const params = new URLSearchParams();
@@ -96,7 +211,7 @@ export default function HalalMonitorPage() {
}
}, [search, typeFilter]);
// ── Fetch usage ────────────────────────────────────────────────────────
// ── Fetch usage ───────────────────────────────────────────
const fetchUsage = useCallback(async () => {
if (!token) return;
try {
@@ -112,7 +227,7 @@ export default function HalalMonitorPage() {
}
}, [token]);
// ── Fetch bookmarks ────────────────────────────────────────────────────
// ── Fetch bookmarks ───────────────────────────────────────
const fetchBookmarks = useCallback(async () => {
if (!token) return;
try {
@@ -128,7 +243,7 @@ export default function HalalMonitorPage() {
}
}, [token]);
// ── Track usage query ──────────────────────────────────────────────────
// ── Track usage query ─────────────────────────────────────
const trackQuery = useCallback(async () => {
if (!token) return;
setUsageLoading(true);
@@ -142,15 +257,9 @@ export default function HalalMonitorPage() {
});
if (res.ok) {
const data = await res.json();
setUsage((prev) =>
prev
? { ...prev, ...data }
: data
);
setUsage((prev) => (prev ? { ...prev, ...data } : data));
} else if (res.status === 429) {
// Rate limited — update usage from error response
const data = await res.json();
setUsage((prev) => prev ? { ...prev, remaining: 0 } : prev);
setUsage((prev) => (prev ? { ...prev, remaining: 0 } : prev));
}
} catch {
// ignore
@@ -159,10 +268,9 @@ export default function HalalMonitorPage() {
}
}, [token]);
// ── Initial load ───────────────────────────────────────────────────────
// ── Initial load ──────────────────────────────────────────
useEffect(() => {
if (loading || !token) return;
const init = async () => {
setPageLoading(true);
await Promise.all([fetchPlaces(), fetchUsage(), fetchBookmarks()]);
@@ -171,7 +279,33 @@ export default function HalalMonitorPage() {
init();
}, [loading, token, fetchPlaces, fetchUsage, fetchBookmarks]);
// ── Search / filter triggers ───────────────────────────────────────────
// ── Compute distances & sort by distance ──────────────────
const sortedPlaces = useMemo(() => {
if (!places.length) return [];
const withDistances = places.map((p) => {
if (userLocation) {
const dist = haversine(
userLocation.lat,
userLocation.lng,
p.lat,
p.lng
);
return { ...p, distance: Math.round(dist * 100) / 100 };
}
return p;
});
if (userLocation) {
return [...withDistances].sort(
(a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity)
);
}
return withDistances;
}, [places, userLocation]);
// Free tier shows ALL place types (rate-limiting stays on query tracking)
const visiblePlaces = sortedPlaces;
// ── Search / filter triggers ──────────────────────────────
const handleSearch = useCallback(() => {
fetchPlaces(search, typeFilter);
if (!isFree) trackQuery();
@@ -186,16 +320,19 @@ export default function HalalMonitorPage() {
[search, fetchPlaces, isFree, trackQuery]
);
// ── Bookmark toggle ────────────────────────────────────────────────────
// ── Bookmark toggle ───────────────────────────────────────
const toggleBookmark = useCallback(
async (place: Place) => {
if (!token) return;
const existing = bookmarks.find((b) => b.itemId === place.id);
if (existing) {
const res = await fetch(`/mobile/api/halal/bookmarks?id=${existing.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
const res = await fetch(
`/mobile/api/halal/bookmarks?id=${existing.id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}
);
if (res.ok) fetchBookmarks();
} else {
const res = await fetch("/mobile/api/halal/bookmarks", {
@@ -221,12 +358,7 @@ export default function HalalMonitorPage() {
[bookmarks]
);
// ── Filter visible places (premium gating) ─────────────────────────────
const visiblePlaces = isFree
? places.filter((p) => p.type === "mosque")
: places;
// ── Render stars ───────────────────────────────────────────────────────
// ── Render stars ──────────────────────────────────────────
const renderStars = (rating: number) => {
const full = Math.floor(rating);
const half = rating - full >= 0.5;
@@ -234,7 +366,11 @@ export default function HalalMonitorPage() {
return (
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: full }, (_, i) => (
<Star key={`full-${i}`} size={12} className="fill-amber-400 text-amber-400" />
<Star
key={`full-${i}`}
size={12}
className="fill-amber-400 text-amber-400"
/>
))}
{half && (
<div className="relative">
@@ -249,12 +385,19 @@ export default function HalalMonitorPage() {
{Array.from({ length: empty }, (_, i) => (
<Star key={`empty-${i}`} size={12} className="text-gray-600" />
))}
<span className="text-xs text-gray-400 ml-1">{rating.toFixed(1)}</span>
<span className="text-xs text-gray-400 ml-1">
{rating.toFixed(1)}
</span>
</span>
);
};
// ── Premium Gating Overlay ─────────────────────────────────────────────
// ── Map center ────────────────────────────────────────────
const mapCenter: [number, number] = userLocation
? [userLocation.lat, userLocation.lng]
: DEFAULT_CENTER;
// ── Premium Gating Overlay ─────────────────────────────────
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
@@ -267,7 +410,7 @@ export default function HalalMonitorPage() {
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* ── Header ─────────────────────────────────────────────────────── */}
{/* ── Header ───────────────────────────────────────── */}
<div className="px-4 pt-6 pb-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-white flex items-center gap-2">
@@ -286,7 +429,12 @@ export default function HalalMonitorPage() {
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, ((usage?.queriesUsed ?? 0) / (usage?.queriesLimit ?? 10)) * 100)}%`,
width: `${Math.min(
100,
((usage?.queriesUsed ?? 0) /
(usage?.queriesLimit ?? 10)) *
100
)}%`,
backgroundColor:
(usage?.remaining ?? 0) <= 2 ? "#ef4444" : "#10b981",
}}
@@ -299,7 +447,7 @@ export default function HalalMonitorPage() {
Discover halal-certified places around you
</p>
{/* ── Premium Upgrade Banner ──────────────────────────────────── */}
{/* ── Premium Upgrade Banner ─────────────────────── */}
{isFree && (
<button
onClick={() => router.push("/upgrade")}
@@ -314,7 +462,7 @@ export default function HalalMonitorPage() {
Upgrade for Unlimited
</p>
<p className="text-xs text-gray-500">
Unlock restaurants + cafes & unlimited queries
Unlock unlimited queries
</p>
</div>
</div>
@@ -326,12 +474,12 @@ export default function HalalMonitorPage() {
{isFree && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400/70">
<Crown size={12} />
Free tier: mosques only &bull; 10 queries/day
Free tier: 10 queries/day
</div>
)}
</div>
{/* ── Search Bar ─────────────────────────────────────────────────── */}
{/* ── Search Bar ───────────────────────────────────── */}
<div className="px-4 mb-3">
<div className="flex items-center gap-2 bg-[#111118] border border-gray-800/60 rounded-2xl px-4 py-3">
<Search size={16} className="text-gray-500 shrink-0" />
@@ -344,19 +492,25 @@ export default function HalalMonitorPage() {
className="flex-1 bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
{search && (
<button onClick={() => { setSearch(""); fetchPlaces("", typeFilter); }}>
<button
onClick={() => {
setSearch("");
fetchPlaces("", typeFilter);
}}
>
<X size={14} className="text-gray-500" />
</button>
)}
</div>
</div>
{/* ── Category Filter ────────────────────────────────────────────── */}
{/* ── Category Filter ──────────────────────────────── */}
<div className="px-4 mb-4 overflow-x-auto">
<div className="flex gap-2 min-w-max pb-1">
{TYPES.map((t) => {
const active = typeFilter === t;
const isDisabled = isFree && t !== "all" && t !== "mosque";
// All filter buttons enabled for free tier (rate limiting stays)
const isDisabled = false;
return (
<button
key={t}
@@ -377,51 +531,58 @@ export default function HalalMonitorPage() {
</div>
</div>
{/* ── Map Placeholder ────────────────────────────────────────────── */}
{/* ── Leaflet Map ──────────────────────────────────── */}
<div className="mx-4 mb-4">
<div
ref={mapRef}
className="w-full h-48 rounded-2xl overflow-hidden relative bg-gradient-to-br from-emerald-900/40 via-[#111118] to-gray-900 border border-gray-800/60"
>
{/* Decorative grid lines */}
<div className="absolute inset-0 opacity-10"
style={{
backgroundImage:
"linear-gradient(rgba(16, 185, 129, 0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(16, 185, 129, 0.3) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
{/* Center pin */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-1">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center animate-pulse">
<MapPin size={18} className="text-emerald-400" />
</div>
<span className="text-xs text-emerald-500/60 font-medium">
{visiblePlaces.length} places found
</span>
</div>
</div>
{/* Map labels */}
{visiblePlaces.slice(0, 5).map((place) => (
<div
key={place.id}
className="absolute w-2 h-2 rounded-full bg-emerald-400 shadow-lg shadow-emerald-500/40 animate-pulse"
style={{
left: `${((place.lng - 101.6) / 0.2) * 50 + 25}%`,
top: `${((3.2 - place.lat) / 0.3) * 50 + 15}%`,
}}
<div className="w-full h-48 rounded-2xl overflow-hidden border border-gray-800/60">
<LeafletMapContainer
center={mapCenter}
zoom={13}
className="w-full h-full"
scrollWheelZoom={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
))}
{/* User location blue dot */}
{icons && userLocation && (
<Marker
position={[userLocation.lat, userLocation.lng]}
icon={icons.user}
>
<Popup>You are here</Popup>
</Marker>
)}
{/* Place markers */}
{icons &&
visiblePlaces.map((place) => (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icons[place.type]}
eventHandlers={{
click: () => setSelectedPlace(place),
}}
>
<Popup>
<div className="text-sm font-semibold">{place.name}</div>
<div className="text-xs text-gray-500">
{place.address}
</div>
</Popup>
</Marker>
))}
</LeafletMapContainer>
</div>
</div>
{/* ── Results List ───────────────────────────────────────────────── */}
{/* ── Results List ─────────────────────────────────── */}
<div className="px-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
{visiblePlaces.length} {visiblePlaces.length === 1 ? "place" : "places"}
{visiblePlaces.length}{" "}
{visiblePlaces.length === 1 ? "place" : "places"}
{userLocation && " · Nearest first"}
</h2>
{usageLoading && (
<Loader2 size={12} className="animate-spin text-gray-600" />
@@ -437,9 +598,7 @@ export default function HalalMonitorPage() {
<MapPin size={32} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No places found</p>
<p className="text-xs text-gray-700 mt-1">
{isFree
? "Free tier shows mosques only. Try a different search."
: "Try adjusting your search or filters."}
Try adjusting your search or filters.
</p>
</div>
) : (
@@ -466,7 +625,11 @@ export default function HalalMonitorPage() {
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
}`}
>
{place.type === "mosque" ? "Mosque" : "Restaurant"}
{place.type === "mosque"
? "Mosque"
: place.type === "restaurant"
? "Restaurant"
: "Cafe"}
</span>
</div>
@@ -478,13 +641,22 @@ export default function HalalMonitorPage() {
{/* Rating */}
<div className="mb-1.5">{renderStars(place.rating)}</div>
{/* Halal badge */}
{place.halal_certified && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
Halal Certified
</span>
)}
{/* Distance & Halal badge */}
<div className="flex items-center gap-2 flex-wrap">
{place.distance !== undefined && (
<span className="text-xs text-blue-400/80">
{place.distance < 1
? `${(place.distance * 1000).toFixed(0)} m`
: `${place.distance.toFixed(1)} km`}
</span>
)}
{place.halal_certified && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
Halal Certified
</span>
)}
</div>
</div>
{/* Bookmark heart */}
@@ -512,7 +684,7 @@ export default function HalalMonitorPage() {
)}
</div>
{/* ── Detail Modal ────────────────────────────────────────────────── */}
{/* ── Detail Modal ──────────────────────────────────── */}
{selectedPlace && (
<div
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
@@ -547,7 +719,11 @@ export default function HalalMonitorPage() {
: "bg-amber-500/15 text-amber-300 border border-amber-500/30"
}`}
>
{selectedPlace.type === "mosque" ? "🕌 Mosque" : "🍽️ Restaurant"}
{selectedPlace.type === "mosque"
? "🕌 Mosque"
: selectedPlace.type === "restaurant"
? "🍽️ Restaurant"
: "☕ Cafe"}
</span>
</div>
@@ -569,7 +745,9 @@ export default function HalalMonitorPage() {
{/* Address */}
<div className="flex items-start gap-2 mb-3">
<MapPin size={14} className="text-gray-500 mt-0.5 shrink-0" />
<p className="text-sm text-gray-400">{selectedPlace.address}</p>
<p className="text-sm text-gray-400">
{selectedPlace.address}
</p>
</div>
{/* Rating */}
@@ -581,6 +759,19 @@ export default function HalalMonitorPage() {
<span className="text-xs text-gray-600">/ 5.0</span>
</div>
{/* Distance (if available) */}
{selectedPlace.distance !== undefined && (
<div className="flex items-center gap-2 mb-4 text-sm text-blue-400">
<MapPin size={14} />
<span>
{selectedPlace.distance < 1
? `${(selectedPlace.distance * 1000).toFixed(0)} m`
: `${selectedPlace.distance.toFixed(1)} km`}{" "}
from your location
</span>
</div>
)}
{/* Coords */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="bg-[#0a0a0f] rounded-xl p-3 text-center">
@@ -597,6 +788,17 @@ export default function HalalMonitorPage() {
</div>
</div>
{/* Open in Google Maps */}
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${selectedPlace.lat},${selectedPlace.lng}`}
target="_blank"
rel="noopener noreferrer"
className="w-full py-3 rounded-2xl text-sm font-semibold transition flex items-center justify-center gap-2 mb-3 bg-blue-500/20 text-blue-300 border border-blue-500/40 hover:bg-blue-500/30"
>
<MapPin size={16} />
Open in Google Maps
</a>
{/* Bookmark button */}
<button
onClick={() => {
+61 -1
View File
@@ -8,7 +8,7 @@ import {
Bot, ShoppingBag, MessageCircle, Wallet,
Flame, Sparkles, Star, ChevronRight,
BookOpen, MapPin, Crown, Zap, TrendingUp,
Gift, Trophy,
Gift, Trophy, CircleDot, Compass,
} from "lucide-react";
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
import PremiumBadge from "@/components/PremiumBadge";
@@ -19,6 +19,7 @@ export default function HomePage() {
const [verse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
const [claiming, setClaiming] = useState(false);
const [claimResult, setClaimResult] = useState<string | null>(null);
const [dhikrTodayCount, setDhikrTodayCount] = useState(0);
useEffect(() => {
if (!loading && !token) router.push("/auth");
@@ -34,6 +35,19 @@ export default function HomePage() {
return () => clearInterval(interval);
}, [token, refreshStreak, refreshXp]);
// Fetch dhikr stats
useEffect(() => {
if (!token) return;
fetch("/mobile/api/dhikr/stats", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (data?.dayStats?.totalCount != null) setDhikrTodayCount(data.dayStats.totalCount);
})
.catch(() => {});
}, [token]);
const handleClaimReward = async () => {
if (!token || claiming) return;
setClaiming(true);
@@ -245,6 +259,52 @@ export default function HomePage() {
</div>
</div>
{/* Dhikr Today */}
<div className="mx-4 mb-5">
<Link href="/dhikr" className="block bg-gradient-to-br from-emerald-900/15 via-[#111118] to-gray-900/80 border border-emerald-800/25 rounded-2xl p-5 active:scale-[0.98] transition-all">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-emerald-900/30 flex items-center justify-center">
<CircleDot size={18} className="text-emerald-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">Dhikr Today</h3>
<p className="text-xs text-gray-500">Digital Tasbih Counter</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-emerald-400 tabular-nums">{dhikrTodayCount.toLocaleString()}</p>
<p className="text-[10px] text-gray-600">counts</p>
</div>
</div>
<div className="flex items-center justify-center gap-3 text-xs text-gray-500">
<span className="text-emerald-400/70">سُبْحَانَ ٱللَّٰهِ</span>
<span className="text-gray-600">·</span>
<span className="text-amber-400/70">ٱلْحَمْدُ لِلَّٰهِ</span>
<span className="text-gray-600">·</span>
<span className="text-sky-400/70">ٱللَّٰهُ أَكْبَرُ</span>
</div>
</Link>
</div>
{/* Qibla Finder */}
<div className="mx-4 mb-5">
<Link href="/qibla" className="block bg-gradient-to-br from-amber-900/15 via-[#111118] to-gray-900/80 border border-amber-800/25 rounded-2xl p-5 active:scale-[0.98] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-amber-900/30 flex items-center justify-center">
<Compass size={18} className="text-amber-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">Qibla Finder</h3>
<p className="text-xs text-gray-500">Find direction to the Kaaba</p>
</div>
</div>
<ChevronRight size={16} className="text-amber-600" />
</div>
</Link>
</div>
{/* Stats Row */}
<div className="mx-4 mb-5">
<div className="flex gap-3">
+832
View File
@@ -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>
);
}
+470
View File
@@ -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>
);
}
+2
View File
@@ -9,12 +9,14 @@ import {
MessageCircle,
Wallet,
User,
Clock,
} from "lucide-react";
const navItems = [
{ href: "/nur", label: "Nur", icon: Bot },
{ href: "/souq", label: "Souq", icon: ShoppingBag },
{ href: "/", label: "Home", icon: null, isHome: true },
{ href: "/prayer", label: "Prayer", icon: Clock },
{ href: "/forum", label: "Forum", icon: MessageCircle },
{ href: "/wallet", label: "Wallet", icon: Wallet },
{ href: "/profile", label: "Profile", icon: User },
+44
View File
@@ -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;
}
+96
View File
@@ -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 (0360)
*/
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 0360
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 (0360)
* @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 };