diff --git a/components/molecules/dashboard/nav-routers.jsx b/components/molecules/dashboard/nav-routers.jsx
index c5792a7a..d8dffedf 100644
--- a/components/molecules/dashboard/nav-routers.jsx
+++ b/components/molecules/dashboard/nav-routers.jsx
@@ -9,7 +9,10 @@ import {
Book,
Play,
LaptopMinimal,
- HeartHandshake
+ HeartHandshake,
+ ShoppingBag,
+ DollarSign,
+ Bookmark,
} from "lucide-react";
import {
SidebarGroup,
@@ -35,6 +38,11 @@ const links = [
link: "/dashboard/library",
icon: Book,
},
+ {
+ name: "Saved",
+ link: "/dashboard/saved",
+ icon: Bookmark,
+ },
{
name: "Spaces",
link: "/dashboard/spaces",
@@ -57,6 +65,16 @@ const links = [
link: "/dashboard/sadaqah",
icon: HeartHandshake,
},
+ {
+ name: "My Purchases",
+ link: "/dashboard/purchases",
+ icon: ShoppingBag,
+ },
+ {
+ name: "Earnings",
+ link: "/dashboard/earnings",
+ icon: DollarSign,
+ },
];
diff --git a/components/organisms/dashboard/PrayerTimesWidget.jsx b/components/organisms/dashboard/PrayerTimesWidget.jsx
new file mode 100644
index 00000000..2ba05972
--- /dev/null
+++ b/components/organisms/dashboard/PrayerTimesWidget.jsx
@@ -0,0 +1,337 @@
+"use client";
+
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import {
+ fetchPrayerTimes,
+ getNextPrayerInfo,
+ getStoredLocation,
+ setStoredLocation,
+ formatTime12h,
+} from "@/lib/services/prayer-times";
+import useAuth from "@/hooks/useAuth";
+import {
+ Moon,
+ Sun,
+ Sunset,
+ Sunrise,
+ CloudSun,
+ MapPin,
+ Clock,
+ Search,
+ RefreshCw,
+ Navigation,
+} from "lucide-react";
+import Button from "@/components/atoms/form/Button";
+import Modal from "@/components/molecules/Modal";
+
+const PRAYER_ICONS = {
+ Fajr: Sunrise,
+ Dhuhr: Sun,
+ Asr: CloudSun,
+ Maghrib: Sunset,
+ Isha: Moon,
+};
+
+export default function PrayerTimesWidget() {
+ const { user } = useAuth();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [location, setLocation] = useState(() => getStoredLocation());
+ const [nextPrayerInfo, setNextPrayerInfo] = useState(null);
+ const [countdownText, setCountdownText] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [cityInput, setCityInput] = useState("");
+ const [countryInput, setCountryInput] = useState("");
+ const timerRef = useRef(null);
+
+ // Load Prayer Data
+ const loadData = useCallback(async (locObj) => {
+ setLoading(true);
+ setError(false);
+ try {
+ const activeLocation = locObj || location || (user?.country ? { city: user.country } : null);
+ const res = await fetchPrayerTimes(activeLocation);
+ if (!res || (res.isFallback && !activeLocation)) {
+ setError(true);
+ } else {
+ setData(res);
+ }
+ } catch (_err) {
+ setError(true);
+ } finally {
+ setLoading(false);
+ }
+ }, [location, user?.country]);
+
+ // Initial load & Geolocation auto-detection
+ useEffect(() => {
+ const stored = getStoredLocation();
+ if (stored) {
+ setLocation(stored);
+ loadData(stored);
+ } else if (typeof window !== "undefined" && navigator.geolocation) {
+ // Try non-blocking geolocation
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const geoLoc = {
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ name: "Current Location",
+ };
+ setLocation(geoLoc);
+ setStoredLocation(geoLoc);
+ loadData(geoLoc);
+ },
+ (_geoErr) => {
+ // Fallback to profile country or default
+ const fallbackLoc = user?.country ? { city: user.country } : null;
+ loadData(fallbackLoc);
+ },
+ { timeout: 5000 }
+ );
+ } else {
+ const fallbackLoc = user?.country ? { city: user.country } : null;
+ loadData(fallbackLoc);
+ }
+ }, [user?.country, loadData]);
+
+ // Live Timer Effect
+ useEffect(() => {
+ if (!data?.timings) return;
+
+ const updateTimer = () => {
+ const info = getNextPrayerInfo(data.timings, data.timezone);
+ setNextPrayerInfo(info);
+
+ const sec = info.remainingSeconds;
+ const hours = Math.floor(sec / 3600);
+ const minutes = Math.floor((sec % 3600) / 60);
+ const seconds = sec % 60;
+
+ const pad = (num) => String(num).padStart(2, "0");
+ setCountdownText(
+ hours > 0
+ ? `${hours}h ${pad(minutes)}m ${pad(seconds)}s`
+ : `${pad(minutes)}m ${pad(seconds)}s`
+ );
+ };
+
+ updateTimer();
+ timerRef.current = setInterval(updateTimer, 1000);
+
+ return () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ };
+ }, [data?.timings, data?.timezone]);
+
+ // Manual Location Search Handler
+ const handleLocationSubmit = (e) => {
+ e.preventDefault();
+ if (!cityInput.trim()) return;
+
+ const newLoc = {
+ city: cityInput.trim(),
+ country: countryInput.trim(),
+ name: countryInput.trim() ? `${cityInput.trim()}, ${countryInput.trim()}` : cityInput.trim(),
+ };
+
+ setLocation(newLoc);
+ setStoredLocation(newLoc);
+ setIsModalOpen(false);
+ loadData(newLoc);
+ };
+
+ // Detect Current GPS Location Button Handler
+ const handleAutoDetectGPS = () => {
+ if (typeof window !== "undefined" && navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const geoLoc = {
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ name: "Current Location",
+ };
+ setLocation(geoLoc);
+ setStoredLocation(geoLoc);
+ setIsModalOpen(false);
+ loadData(geoLoc);
+ },
+ () => {
+ alert("Geolocation access denied or unavailable.");
+ }
+ );
+ }
+ };
+
+ return (
+ <>
+
+ {/* Top Header: Dates & Location */}
+
+
+
+
+ {data?.hijriDate || "Hijri Date"}
+
+
+ {data?.gregorianDate}
+
+
+
+ {/* Location Badge */}
+
+
+
+ {/* Loading State */}
+ {loading ? (
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ ) : error ? (
+ /* Error State */
+
+
+ Unable to load prayer times.
+
+
+
+ ) : (
+ <>
+ {/* Next Prayer Live Banner */}
+ {nextPrayerInfo && (
+
+
+
+
+
+
+
+ {nextPrayerInfo.isTomorrow ? "Tomorrow's Next Prayer" : "Upcoming Prayer"}
+
+
+ {nextPrayerInfo.nextPrayerName} at {nextPrayerInfo.nextPrayerTimeFormatted}
+
+
+
+
+ {/* Countdown Badge */}
+
+ In {countdownText}
+
+
+ )}
+
+ {/* 5 Daily Prayer Cards */}
+
+ {["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"].map((name) => {
+ const IconComponent = PRAYER_ICONS[name] || Sun;
+ const rawTime = data?.timings?.[name];
+ const formattedTime = formatTime12h(rawTime);
+ const isNext = nextPrayerInfo?.nextPrayerName === name;
+
+ return (
+
+
+
+ {name}
+
+
+ {formattedTime}
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+ {/* Manual Location Selection Modal */}
+
setIsModalOpen(false)}
+ title="Set Location for Prayer Times"
+ className="max-w-sm w-full"
+ >
+
+
+ >
+ );
+}
diff --git a/docs/screenshots/courses.png b/docs/screenshots/courses.png
new file mode 100644
index 00000000..ade793a2
Binary files /dev/null and b/docs/screenshots/courses.png differ
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
new file mode 100644
index 00000000..6769746f
Binary files /dev/null and b/docs/screenshots/dashboard.png differ
diff --git a/docs/screenshots/landing.png b/docs/screenshots/landing.png
new file mode 100644
index 00000000..3dcb2a6c
Binary files /dev/null and b/docs/screenshots/landing.png differ
diff --git a/docs/screenshots/library.png b/docs/screenshots/library.png
new file mode 100644
index 00000000..d722dd3a
Binary files /dev/null and b/docs/screenshots/library.png differ
diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png
new file mode 100644
index 00000000..5ec5c5ad
Binary files /dev/null and b/docs/screenshots/login.png differ
diff --git a/docs/screenshots/wallet.png b/docs/screenshots/wallet.png
new file mode 100644
index 00000000..1c6bbd3c
Binary files /dev/null and b/docs/screenshots/wallet.png differ
diff --git a/hooks/useAuth.js b/hooks/useAuth.js
index 9898b9a4..97dc4fc6 100644
--- a/hooks/useAuth.js
+++ b/hooks/useAuth.js
@@ -35,6 +35,19 @@ export const useAuth = () => {
Cookies.remove("userInfo", { path: "/" });
setUser(null);
setIsAuthenticated(false);
+
+ if (typeof window !== "undefined" && "caches" in window) {
+ caches.keys().then((names) => {
+ const runtimeCaches = names.filter(
+ (name) =>
+ name.startsWith("serwist-") || name === "book-previews"
+ );
+ return Promise.all(
+ runtimeCaches.map((name) => caches.delete(name))
+ );
+ }).catch(() => {});
+ }
+
toast.success("Logged out successfully");
router.push("/");
};
diff --git a/hooks/useBookBookmark.js b/hooks/useBookBookmark.js
index 4bd8d208..9613d6df 100644
--- a/hooks/useBookBookmark.js
+++ b/hooks/useBookBookmark.js
@@ -1,58 +1,14 @@
-import { useEffect, useState } from "react";
-import {
- toggleBookBookmark,
- checkIfBookBookmarked,
-} from "@/lib/actions/library/bookmark-book";
-import { toast } from "sonner";
-import useAuth from "./useAuth";
-
-export const useBookBookmark = (bookId, onToggle) => {
- const { user } = useAuth();
- const [isBookmarked, setIsBookmarked] = useState(false);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- const checkBookmark = async () => {
- if (user?._id && bookId) {
- try {
- const bookmarked = await checkIfBookBookmarked(bookId);
- setIsBookmarked(Boolean(bookmarked));
- } catch (_error) {
- // ignore
- }
- }
- };
-
- checkBookmark();
- }, [user?._id, bookId]);
-
- const toggle = async () => {
- if (!user) {
- toast.error("Please login to bookmark books");
- return;
- }
-
- setLoading(true);
- try {
- const result = await toggleBookBookmark(bookId);
- setIsBookmarked(result.isBookmarked);
- toast.success(result.message);
- if (onToggle) {
- onToggle(result.isBookmarked);
- }
- } catch (_error) {
- toast.error("Failed to update book bookmark");
- } finally {
- setLoading(false);
- }
- };
-
- return {
- isBookmarked,
- loading,
- toggle,
- };
+import { useBookmarkCore } from "./useBookmarkCore";
+
+/**
+ * Custom hook for managing book bookmarks
+ * @param {string} bookId - The book ID
+ * @param {function} [onToggle] - Optional callback when bookmark is toggled
+ * @param {boolean|null} [initialIsBookmarked=null] - Optional pre-seeded bookmark state
+ * @returns {Object} - Bookmark state and toggle function
+ */
+export const useBookBookmark = (bookId, onToggle, initialIsBookmarked = null) => {
+ return useBookmarkCore(bookId, "book", onToggle, initialIsBookmarked);
};
export default useBookBookmark;
-
diff --git a/hooks/useBookmark.js b/hooks/useBookmark.js
index d16ec7a6..49451cf7 100644
--- a/hooks/useBookmark.js
+++ b/hooks/useBookmark.js
@@ -1,65 +1,14 @@
-import { useState, useEffect } from "react";
-import {
- toggleCourseBookmark,
- checkIfBookmarked,
-} from "@/lib/actions/courses/bookmark-course";
-import { toast } from "sonner";
-import useAuth from "./useAuth";
+import { useBookmarkCore } from "./useBookmarkCore";
/**
* Custom hook for managing course bookmarks
* @param {string} courseId - The course ID
- * @param {function} onToggle - Optional callback when bookmark is toggled
+ * @param {function} [onToggle] - Optional callback when bookmark is toggled
+ * @param {boolean|null} [initialIsBookmarked=null] - Optional pre-seeded bookmark state
* @returns {Object} - Bookmark state and toggle function
*/
-export const useBookmark = (courseId, onToggle) => {
- const { user } = useAuth();
- const [isBookmarked, setIsBookmarked] = useState(false);
- const [loading, setLoading] = useState(false);
-
- // Check if course is bookmarked on mount
- useEffect(() => {
- const checkBookmark = async () => {
- if (user?._id && courseId) {
- try {
- const bookmarked = await checkIfBookmarked(courseId);
- setIsBookmarked(bookmarked);
- } catch (error) {
- // Silently fail - not critical
- }
- }
- };
- checkBookmark();
- }, [user?._id, courseId]);
-
- const toggle = async () => {
- if (!user) {
- toast.error("Please login to bookmark courses");
- return;
- }
-
- setLoading(true);
- try {
- const result = await toggleCourseBookmark(courseId);
- setIsBookmarked(result.isBookmarked);
- toast.success(result.message);
-
- // Call optional callback
- if (onToggle) {
- onToggle(result.isBookmarked);
- }
- } catch (error) {
- toast.error("Failed to update bookmark");
- } finally {
- setLoading(false);
- }
- };
-
- return {
- isBookmarked,
- loading,
- toggle,
- };
+export const useBookmark = (courseId, onToggle, initialIsBookmarked = null) => {
+ return useBookmarkCore(courseId, "course", onToggle, initialIsBookmarked);
};
export default useBookmark;
diff --git a/hooks/useBookmarkCore.js b/hooks/useBookmarkCore.js
new file mode 100644
index 00000000..6aa96412
--- /dev/null
+++ b/hooks/useBookmarkCore.js
@@ -0,0 +1,125 @@
+import { useState, useEffect, useRef } from "react";
+import {
+ toggleCourseBookmark,
+ checkIfBookmarked,
+} from "@/lib/actions/courses/bookmark-course";
+import {
+ toggleBookBookmark,
+ checkIfBookBookmarked,
+} from "@/lib/actions/library/bookmark-book";
+import { toast } from "sonner";
+import useAuth from "./useAuth";
+
+/**
+ * Core custom hook for managing bookmarks (courses & books)
+ * @param {string} itemId - Item ID (course or book ID)
+ * @param {'course'|'book'} type - Type of item
+ * @param {function} [onToggle] - Optional callback when bookmark is toggled
+ * @param {boolean|null} [initialIsBookmarked=null] - Pre-seeded bookmark state to avoid mount check API call
+ * @returns {Object} - { isBookmarked, loading, toggle }
+ */
+export const useBookmarkCore = (
+ itemId,
+ type = "course",
+ onToggle,
+ initialIsBookmarked = null
+) => {
+ const { user } = useAuth();
+ const hasToggledRef = useRef(false);
+ const [isBookmarked, setIsBookmarked] = useState(() => {
+ return typeof initialIsBookmarked === "boolean" ? initialIsBookmarked : false;
+ });
+ const [loading, setLoading] = useState(false);
+
+ // Sync initialIsBookmarked if it changes dynamically
+ useEffect(() => {
+ if (typeof initialIsBookmarked === "boolean") {
+ setIsBookmarked(initialIsBookmarked);
+ }
+ }, [initialIsBookmarked]);
+
+ // Check bookmark status on mount only if initialIsBookmarked was not provided
+ useEffect(() => {
+ const checkBookmark = async () => {
+ if (typeof initialIsBookmarked === "boolean") {
+ return; // Skip network request when pre-seeded
+ }
+
+ if (user?._id && itemId) {
+ try {
+ if (type === "course") {
+ const bookmarked = await checkIfBookmarked(itemId);
+ if (!hasToggledRef.current) {
+ setIsBookmarked(Boolean(bookmarked));
+ }
+ } else if (type === "book") {
+ const bookmarked = await checkIfBookBookmarked(itemId);
+ if (!hasToggledRef.current) {
+ setIsBookmarked(Boolean(bookmarked));
+ }
+ }
+ } catch (_error) {
+ // Silently handle error for check request
+ }
+ }
+ };
+
+ checkBookmark();
+ }, [user?._id, itemId, type, initialIsBookmarked]);
+
+ const toggle = async () => {
+ if (!user) {
+ toast.error(`Please login to bookmark ${type === "book" ? "books" : "courses"}`);
+ return;
+ }
+
+ hasToggledRef.current = true;
+ const previousState = isBookmarked;
+ const nextState = !previousState;
+
+ // Optimistic update
+ setIsBookmarked(nextState);
+ if (onToggle) {
+ onToggle(nextState);
+ }
+
+ setLoading(true);
+
+ try {
+ let result;
+ if (type === "course") {
+ result = await toggleCourseBookmark(itemId);
+ } else {
+ result = await toggleBookBookmark(itemId);
+ }
+
+ if (result && typeof result.isBookmarked === "boolean") {
+ setIsBookmarked(result.isBookmarked);
+ }
+ if (result?.message) {
+ toast.success(result.message);
+ }
+ } catch (error) {
+ // Revert optimistic update on failure
+ setIsBookmarked(previousState);
+ if (onToggle) {
+ onToggle(previousState);
+ }
+ const errorMessage =
+ error?.message ||
+ error?.error ||
+ `Failed to update ${type === "book" ? "book bookmark" : "bookmark"}`;
+ toast.error(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ isBookmarked,
+ loading,
+ toggle,
+ };
+};
+
+export default useBookmarkCore;
diff --git a/hooks/useEarnings.js b/hooks/useEarnings.js
new file mode 100644
index 00000000..8657aee6
--- /dev/null
+++ b/hooks/useEarnings.js
@@ -0,0 +1,240 @@
+"use client";
+import { useState, useEffect, useCallback, useMemo } from "react";
+import useStellarPayment from "./useStellarPayment";
+import useAuth from "./useAuth";
+import { useStellar } from "@/components/stellar/StellarProvider";
+import { fetchUserCourses } from "@/lib/actions/courses/fetch-user-id-courses";
+import { fetchUserBooks } from "@/lib/actions/library/fetch-user-id-books";
+import dayjs from "dayjs";
+
+const CONFIRMED = "confirmed";
+
+function aggregateByDay(transactions) {
+ const map = {};
+ for (const tx of transactions) {
+ const day = dayjs(tx.createdAt).format("YYYY-MM-DD");
+ map[day] = (map[day] || 0) + tx.amount;
+ }
+ return Object.entries(map)
+ .map(([date, revenue]) => ({ date, revenue }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+function aggregateByWeek(transactions) {
+ const map = {};
+ for (const tx of transactions) {
+ const week = dayjs(tx.createdAt).startOf("week").format("YYYY-MM-DD");
+ map[week] = (map[week] || 0) + tx.amount;
+ }
+ return Object.entries(map)
+ .map(([date, revenue]) => ({ date, revenue }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+function getLastMonthRange() {
+ const now = dayjs();
+ const startOfThisMonth = now.startOf("month");
+ const startOfLastMonth = startOfThisMonth.subtract(1, "month");
+ const endOfLastMonth = startOfThisMonth.subtract(1, "day");
+ return { startOfLastMonth, endOfLastMonth, startOfThisMonth };
+}
+
+export default function useEarnings() {
+ const { getTransactionHistory } = useStellarPayment();
+ const { user } = useAuth();
+ const { connectedWallet, walletInfo } = useStellar();
+
+ const [allTransactions, setAllTransactions] = useState([]);
+ const [creatorItems, setCreatorItems] = useState({ courses: [], books: [] });
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchAllCreatorTransactions = useCallback(async () => {
+ let all = [];
+ let page = 1;
+ let totalPages = 1;
+
+ while (page <= totalPages) {
+ const result = await getTransactionHistory({
+ role: "creator",
+ page,
+ limit: 100,
+ });
+ if (result.success) {
+ all = [...all, ...result.transactions];
+ totalPages = result.pagination?.pages || 1;
+ }
+ page++;
+ }
+
+ return all;
+ }, [getTransactionHistory]);
+
+ const fetchCreatorItems = useCallback(async () => {
+ if (!user?._id) return { courses: [], books: [] };
+ const [courses, books] = await Promise.allSettled([
+ fetchUserCourses(user._id),
+ fetchUserBooks(user._id),
+ ]);
+ return {
+ courses: courses.status === "fulfilled" ? courses.value : [],
+ books: books.status === "fulfilled" ? books.value : [],
+ };
+ }, [user]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const [transactions, items] = await Promise.all([
+ fetchAllCreatorTransactions(),
+ fetchCreatorItems(),
+ ]);
+ if (!cancelled) {
+ setAllTransactions(transactions);
+ setCreatorItems(items);
+ }
+ } catch (err) {
+ if (!cancelled) setError(err.message || "Failed to load earnings data");
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ };
+
+ load();
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchAllCreatorTransactions, fetchCreatorItems]);
+
+ const confirmed = useMemo(
+ () => allTransactions.filter((tx) => tx.status === CONFIRMED),
+ [allTransactions]
+ );
+
+ const pending = useMemo(
+ () => allTransactions.filter((tx) => tx.status === "pending" || tx.status === "submitted"),
+ [allTransactions]
+ );
+
+ const failed = useMemo(
+ () => allTransactions.filter((tx) => tx.status === "failed" || tx.status === "expired"),
+ [allTransactions]
+ );
+
+ const totalEarned = useMemo(
+ () => confirmed.reduce((sum, tx) => sum + (tx.amount || 0), 0),
+ [confirmed]
+ );
+
+ const salesCount = confirmed.length;
+
+ const { thisMonthRevenue, lastMonthRevenue, monthOverMonthChange } = useMemo(() => {
+ const { startOfLastMonth, endOfLastMonth, startOfThisMonth } = getLastMonthRange();
+ const thisMonth = confirmed.filter((tx) =>
+ dayjs(tx.createdAt).isAfter(startOfThisMonth)
+ );
+ const lastMonth = confirmed.filter((tx) => {
+ const d = dayjs(tx.createdAt);
+ return d.isAfter(startOfLastMonth) && d.isBefore(endOfLastMonth);
+ });
+ const thisAmt = thisMonth.reduce((s, t) => s + (t.amount || 0), 0);
+ const lastAmt = lastMonth.reduce((s, t) => s + (t.amount || 0), 0);
+ const change = lastAmt > 0 ? ((thisAmt - lastAmt) / lastAmt) * 100 : thisAmt > 0 ? 100 : 0;
+ return {
+ thisMonthRevenue: thisAmt,
+ lastMonthRevenue: lastAmt,
+ monthOverMonthChange: Math.round(change * 10) / 10,
+ };
+ }, [confirmed]);
+
+ const revenueChartData = useCallback(
+ (range) => {
+ let filtered = confirmed;
+ const now = dayjs();
+ if (range === "7d") {
+ filtered = confirmed.filter((tx) =>
+ dayjs(tx.createdAt).isAfter(now.subtract(7, "day"))
+ );
+ } else if (range === "30d") {
+ filtered = confirmed.filter((tx) =>
+ dayjs(tx.createdAt).isAfter(now.subtract(30, "day"))
+ );
+ }
+ if (range === "7d") return aggregateByDay(filtered);
+ return aggregateByWeek(filtered);
+ },
+ [confirmed]
+ );
+
+ const topItems = useMemo(() => {
+ const map = {};
+ for (const tx of confirmed) {
+ const key = `${tx.itemType}:${tx.itemTitle}`;
+ if (!map[key]) {
+ map[key] = {
+ itemType: tx.itemType,
+ itemTitle: tx.itemTitle,
+ revenue: 0,
+ units: 0,
+ };
+ }
+ map[key].revenue += tx.amount || 0;
+ map[key].units += 1;
+ }
+ return Object.values(map).sort((a, b) => b.revenue - a.revenue);
+ }, [confirmed]);
+
+ const findItemLink = useCallback(
+ (title, type) => {
+ if (type === "course") {
+ const found = creatorItems.courses.find(
+ (c) => c.title === title || c.title?.includes(title) || title?.includes(c.title)
+ );
+ return found ? `/dashboard/courses/${found._id || found.id}` : null;
+ }
+ if (type === "book") {
+ const found = creatorItems.books.find(
+ (b) => b.title === title || b.title?.includes(title) || title?.includes(b.title)
+ );
+ return found ? `/dashboard/library/${found._id || found.id}` : null;
+ }
+ return null;
+ },
+ [creatorItems]
+ );
+
+ const statusBreakdown = useMemo(() => {
+ const counts = { confirmed: 0, pending: 0, submitted: 0, failed: 0, expired: 0 };
+ for (const tx of allTransactions) {
+ if (counts[tx.status] !== undefined) counts[tx.status]++;
+ }
+ return counts;
+ }, [allTransactions]);
+
+ const withdrawableBalance = walletInfo?.usdcBalance
+ ? parseFloat(walletInfo.usdcBalance)
+ : 0;
+
+ return {
+ isLoading,
+ error,
+ hasWallet: !!connectedWallet,
+ totalEarned,
+ salesCount,
+ thisMonthRevenue,
+ lastMonthRevenue,
+ monthOverMonthChange,
+ revenueChartData,
+ topItems,
+ findItemLink,
+ statusBreakdown,
+ withdrawableBalance,
+ confirmedCount: confirmed.length,
+ pendingCount: pending.length,
+ failedCount: failed.length,
+ };
+}
diff --git a/hooks/usePurchases.js b/hooks/usePurchases.js
new file mode 100644
index 00000000..0fa463fc
--- /dev/null
+++ b/hooks/usePurchases.js
@@ -0,0 +1,169 @@
+"use client";
+import { useState, useEffect, useCallback, useMemo } from "react";
+import useAuth from "./useAuth";
+import useStellarPayment from "./useStellarPayment";
+import { fetchCourses } from "@/lib/actions/courses/fetch-courses";
+import { fetchBooks } from "@/lib/actions/library/fetch-books";
+import axiosInstance from "@/lib/config/axios.config";
+
+export default function usePurchases() {
+ const { user } = useAuth();
+ const { getTransactionHistory } = useStellarPayment();
+
+ const [courses, setCourses] = useState([]);
+ const [books, setBooks] = useState([]);
+ const [transactions, setTransactions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const ownedCourseIds = useMemo(() => {
+ if (!user) return new Set();
+ const ids = new Set();
+ if (Array.isArray(user.purchasedCourses)) {
+ user.purchasedCourses.forEach((c) => {
+ if (c.courseId) ids.add(c.courseId.toString());
+ if (c._id) ids.add(c._id.toString());
+ });
+ }
+ if (Array.isArray(user.enrolledCourses)) {
+ user.enrolledCourses.forEach((id) => ids.add(id.toString()));
+ }
+ return ids;
+ }, [user]);
+
+ const ownedBookIds = useMemo(() => {
+ if (!user) return new Set();
+ const ids = new Set();
+ if (Array.isArray(user.purchasedBooks)) {
+ user.purchasedBooks.forEach((b) => {
+ if (b.bookId) ids.add(b.bookId.toString());
+ if (b._id) ids.add(b._id.toString());
+ });
+ }
+ return ids;
+ }, [user]);
+
+ const fetchAllBuyerTransactions = useCallback(async () => {
+ let all = [];
+ let page = 1;
+ let totalPages = 1;
+
+ while (page <= totalPages) {
+ const result = await getTransactionHistory({
+ role: "buyer",
+ page,
+ limit: 100,
+ });
+ if (result.success) {
+ all = [...all, ...result.transactions];
+ totalPages = result.pagination?.pages || 1;
+ }
+ page++;
+ }
+
+ return all;
+ }, [getTransactionHistory]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const [allCourses, allBooks, buyerTxs] = await Promise.all([
+ fetchCourses(),
+ fetchBooks(),
+ fetchAllBuyerTransactions(),
+ ]);
+
+ if (!cancelled) {
+ const coursesList = Array.isArray(allCourses) ? allCourses : [];
+ const booksList = Array.isArray(allBooks) ? allBooks : [];
+
+ const ownedCourses = coursesList.filter((c) =>
+ ownedCourseIds.has(c._id?.toString())
+ );
+ const ownedBooks = booksList.filter((b) =>
+ ownedBookIds.has(b._id?.toString())
+ );
+
+ setCourses(ownedCourses);
+ setBooks(ownedBooks);
+ setTransactions(buyerTxs);
+ }
+ } catch (err) {
+ if (!cancelled) setError(err.message || "Failed to load purchases");
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ };
+
+ if (user) load();
+ else setIsLoading(false);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [user, ownedCourseIds, ownedBookIds, fetchAllBuyerTransactions]);
+
+ const transactionMap = useMemo(() => {
+ const map = {};
+ for (const tx of transactions) {
+ if (tx.status === "confirmed" && tx.itemType && tx.itemTitle) {
+ map[`${tx.itemType}:${tx.itemTitle}`] = tx;
+ }
+ }
+ return map;
+ }, [transactions]);
+
+ const getReceipt = useCallback(
+ (itemId, itemType, itemTitle) => {
+ const tx = transactionMap[`${itemType}:${itemTitle}`];
+ if (!tx) return null;
+ return {
+ amount: tx.amount,
+ status: tx.status,
+ createdAt: tx.createdAt,
+ buyerWallet: tx.buyerWallet,
+ creatorWallet: tx.creatorWallet,
+ creatorName: tx.creator?.name,
+ buyerName: tx.buyer?.name,
+ explorerUrl: tx.explorerUrl,
+ _id: tx._id,
+ };
+ },
+ [transactionMap]
+ );
+
+ const isFreeItem = useCallback(
+ (itemId, itemType) => {
+ if (itemType === "course") {
+ return !user?.purchasedCourses?.some(
+ (c) =>
+ c.courseId?.toString() === itemId?.toString() ||
+ c._id?.toString() === itemId?.toString()
+ );
+ }
+ if (itemType === "book") {
+ return !user?.purchasedBooks?.some(
+ (b) =>
+ b.bookId?.toString() === itemId?.toString() ||
+ b._id?.toString() === itemId?.toString()
+ );
+ }
+ return true;
+ },
+ [user]
+ );
+
+ return {
+ isLoading,
+ error,
+ courses,
+ books,
+ getReceipt,
+ isFreeItem,
+ isEmpty: courses.length === 0 && books.length === 0,
+ };
+}
diff --git a/lib/services/prayer-times.js b/lib/services/prayer-times.js
new file mode 100644
index 00000000..aac98d1c
--- /dev/null
+++ b/lib/services/prayer-times.js
@@ -0,0 +1,265 @@
+/**
+ * Isolated Prayer Times & Hijri Date Service
+ * Handles location resolution, API fetching (Aladhan REST API), caching,
+ * fallback calculations, Hijri date formatting, and next-prayer countdown calculations.
+ */
+
+const CACHE_KEY = "dnb_prayer_times_cache";
+const LOCATION_STORAGE_KEY = "dnb_prayer_location";
+
+/**
+ * Format Hijri Date using Intl API as an offline-friendly fallback
+ * @param {Date} date
+ * @returns {string} e.g. "14 Safar 1446 AH"
+ */
+export function getFallbackHijriDate(date = new Date()) {
+ try {
+ const formatter = new Intl.DateTimeFormat("en-US-u-ca-islamic-umalqura", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+ return `${formatter.format(date)} AH`;
+ } catch (_e) {
+ try {
+ const fallbackFormatter = new Intl.DateTimeFormat("en-US-u-ca-islamic", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+ return `${fallbackFormatter.format(date)} AH`;
+ } catch (_err) {
+ return "1446 AH";
+ }
+ }
+}
+
+/**
+ * Get stored location preference from localStorage
+ * @returns {Object|null}
+ */
+export function getStoredLocation() {
+ if (typeof window === "undefined") return null;
+ try {
+ const data = localStorage.getItem(LOCATION_STORAGE_KEY);
+ return data ? JSON.parse(data) : null;
+ } catch (_e) {
+ return null;
+ }
+}
+
+/**
+ * Save location preference to localStorage
+ * @param {Object} locationObj
+ */
+export function setStoredLocation(locationObj) {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(locationObj));
+ } catch (_e) {
+ // Ignore storage errors
+ }
+}
+
+/**
+ * Convert 24-hour time ("14:30") to 12-hour formatted time ("2:30 PM")
+ * @param {string} timeStr
+ * @returns {string}
+ */
+export function formatTime12h(timeStr) {
+ if (!timeStr) return "";
+ const cleanTime = timeStr.split(" ")[0]; // Remove timezone offset if present
+ const [hoursStr, minutesStr] = cleanTime.split(":");
+ let hours = parseInt(hoursStr, 10);
+ const minutes = minutesStr || "00";
+ if (isNaN(hours)) return timeStr;
+
+ const ampm = hours >= 12 ? "PM" : "AM";
+ hours = hours % 12;
+ hours = hours ? hours : 12; // Hour 0 should be 12
+ return `${hours}:${minutes} ${ampm}`;
+}
+
+/**
+ * Fetch today's prayer times and Hijri date from Aladhan API with caching
+ * @param {Object} location - { lat, lng, city, country, name }
+ * @returns {Promise