diff --git a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx index 2ac94bc..270c81d 100644 --- a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx +++ b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx @@ -1,66 +1,123 @@ // components/blocks/Hero.jsx +import { useEffect, useState } from "react"; import { Megaphone } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel"; +import { Progress } from "@/components/ui/progress"; import { resolveAssetSrc } from "@/utils/media.util"; // ── Hero ───────────────────────────────────────────────────────────────────── /** - * Generic hero advertisement block. - * Two-column layout: badge/headline/description/CTAs on the left, image on the right. - * Renders null when no ad is provided — callers should not fall back to placeholder copy. + * Hero advertisement carousel. + * Each slide is a full-bleed background image with a bottom gradient overlay, + * badge/headline/description/CTAs anchored bottom-left. Renders null when no + * ads are given — callers should not fall back to placeholder copy. * * Props: - * ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id } + * ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id } * onCtaClick — (ad, cta) => void, called when any CTA button is clicked */ -export function Hero({ ad, onCtaClick }) { - if (!ad) return null; +export function Hero({ ads, onCtaClick }) { + const [api, setApi] = useState(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); - const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; - const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; + useEffect(() => { + if (!api) return; + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + if (!ads?.length) return null; + + const progressValue = count ? (current / count) * 100 : 0; return ( -
-
- {ad.badge_label && ( - - {ad.badge_label} - - )} - {ad.headline && ( -
- {ad.headline} +
+ + + {ads.map((ad) => { + const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; + const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; + + return ( + + + + {imageSrc ? ( + {ad.headline + ) : ( + + )} + + {/* Bottom gradient overlay */} +
+ + {/* Bottom-left content */} +
+ {ad.badge_label && ( + + {ad.badge_label} + + )} + {ad.headline && ( +
+ {ad.headline} +
+ )} + {ad.description && ( +

+ {ad.description} +

+ )} + {ctas.length > 0 && ( +
+ {ctas.map((cta, i) => ( + + ))} +
+ )} +
+ + + + ); + })} + + + {/* Navigation and Progress — only meaningful with more than one slide */} + {ads.length > 1 && ( +
+
+ + +
+
+ +
)} - {ad.description && ( -

- {ad.description} -

- )} - {ctas.length > 0 && ( -
- {ctas.map((cta, i) => ( - - ))} -
- )} -
-
- {imageSrc ? ( - {ad.headline - ) : ( - - )} -
+
); } @@ -69,18 +126,22 @@ export function Hero({ ad, onCtaClick }) { export function HeroSkeleton() { return ( -
-
- - - - -
- - -
-
- +
+ + + +
+ + + + +
+ + +
+
+
+
); -} \ No newline at end of file +} diff --git a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx index df355d3..8d5072f 100644 --- a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx +++ b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx @@ -7,6 +7,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { cn } from "@/lib/utils"; /** * AppBreadcrumb @@ -25,7 +26,12 @@ import { * * ───────────────────────────────────────────────────────────────────────────── * - * @param {Array} items Ordered list of breadcrumb item definitions + * @param {Array} items Ordered list of breadcrumb item definitions + * @param {Object} [color] Independent color overrides per element + * @param {Object} [color.link] Override for non-last (clickable) items + * @param {string} [color.link.color] className applied to BreadcrumbLink + * @param {Object} [color.page] Override for the last (current page) item + * @param {string} [color.page.color] className applied to BreadcrumbPage * * ─── Usage ─────────────────────────────────────────────────────────────────── * @@ -49,10 +55,18 @@ import { * ]} * /> * + * // With per-element color overrides + * + * * ───────────────────────────────────────────────────────────────────────────── */ -const AppBreadcrumb = ({ items = [] }) => { +const AppBreadcrumb = ({ items = [], color = {} }) => { const navigate = useNavigate(); + const linkColor = color.link?.color ?? ""; + const pageColor = color.page?.color ?? ""; if (!items.length) return null; @@ -66,14 +80,14 @@ const AppBreadcrumb = ({ items = [] }) => { {isLast ? ( - + {item.icon} {item.label} ) : (
{ if (item.onClick) { item.onClick(e, navigate); diff --git a/src/components/generic/DashboardGrid.jsx b/src/components/generic/DashboardGrid.jsx index 1071b00..13e0406 100644 --- a/src/components/generic/DashboardGrid.jsx +++ b/src/components/generic/DashboardGrid.jsx @@ -30,17 +30,11 @@ export default function DashboardGrid({ sections = [] }) { handleNavigate(e, link)} - className="group bg-card border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer" - whileHover={{ - y: -6, - scale: 1.02, - backgroundColor: "var(--primary)", - color: "var(--motion-card-hover)" - }} + className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out" + whileHover={{ y: -6, scale: 1.02 }} transition={{ y: { type: "spring", stiffness: 300, damping: 20 }, scale: { type: "spring", stiffness: 300, damping: 20 }, - backgroundColor: { duration: 0.2, ease: "easeOut" }, }} > - + {label} diff --git a/src/components/generic/UserMenu.jsx b/src/components/generic/UserMenu.jsx index 18b146b..2686a75 100644 --- a/src/components/generic/UserMenu.jsx +++ b/src/components/generic/UserMenu.jsx @@ -186,6 +186,7 @@ function SignOutOverlay({ open }) { // ─── Main UserMenu ──────────────────────────────────────────────────────────── export default function UserMenu() { const { user, logout } = useAuth() + const { setTheme } = useTheme() const { avatarUrl } = useProfile() const navigate = useNavigate() @@ -207,6 +208,7 @@ export default function UserMenu() { const handleLogout = async () => { setSigningOut(true) await logout() + setTheme('light') navigate('/login', { replace: true }) } diff --git a/src/components/ui/progress.jsx b/src/components/ui/progress.jsx index 0b3a879..ca6bc6d 100644 --- a/src/components/ui/progress.jsx +++ b/src/components/ui/progress.jsx @@ -20,7 +20,7 @@ function Progress({ {...props}> = 100 ? "bg-green-500" : "bg-primary")} + className="size-full flex-1 bg-primary transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> ); diff --git a/src/contexts/AdminAchievementsContext.jsx b/src/contexts/AdminAchievementsContext.jsx index db82cac..9b45bba 100644 --- a/src/contexts/AdminAchievementsContext.jsx +++ b/src/contexts/AdminAchievementsContext.jsx @@ -19,7 +19,12 @@ export function AdminAchievementsProvider({ children }) { setLoading(true); try { return await fn(); } catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); + toast(err?.response?.data?.message ?? "Something went wrong.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -41,7 +46,12 @@ export function AdminAchievementsProvider({ children }) { const createAchievement = useCallback((payload) => request(async () => { const { data } = await api.post("/admin/achievements", payload); - toast.success("Achievement created."); + toast("Achievement created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request]); @@ -52,7 +62,12 @@ export function AdminAchievementsProvider({ children }) { prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a)) ); if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data); - toast.success("Achievement updated."); + toast("Achievement updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request, achievement]); @@ -60,7 +75,12 @@ export function AdminAchievementsProvider({ children }) { request(async () => { await api.delete(`/admin/achievements/${id}`); setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id))); - toast.success("Achievement deleted."); + toast("Achievement deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request]); diff --git a/src/contexts/AdminAdvertisementContext.jsx b/src/contexts/AdminAdvertisementContext.jsx index bd2ff31..e04835c 100644 --- a/src/contexts/AdminAdvertisementContext.jsx +++ b/src/contexts/AdminAdvertisementContext.jsx @@ -34,7 +34,12 @@ export function AdvertisementsProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -100,7 +105,12 @@ export function AdvertisementsProvider({ children }) { const advertisement = res.data?.data?.data ?? null; if (advertisement) { setAdvertisements((prev) => [advertisement, ...prev]); - toast.success("Advertisement created successfully."); + toast("Advertisement created successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -116,7 +126,12 @@ export function AdvertisementsProvider({ children }) { if (advertisement) { setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a))); setSelectedAdvertisement(advertisement); - toast.success("Advertisement updated successfully."); + toast("Advertisement updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -132,7 +147,12 @@ export function AdvertisementsProvider({ children }) { }); setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId)); setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev)); - toast.success("Advertisement archived."); + toast("Advertisement archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -146,7 +166,12 @@ export function AdvertisementsProvider({ children }) { data: { ids, deletedBy }, }); setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id))); - toast.success(`${ids.length} advertisement(s) archived.`); + toast(`${ids.length} advertisement(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -160,7 +185,12 @@ export function AdvertisementsProvider({ children }) { const advertisement = res.data?.data?.data ?? null; if (advertisement) { setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId)); - toast.success("Advertisement restored."); + toast("Advertisement restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -173,7 +203,12 @@ export function AdvertisementsProvider({ children }) { request(async () => { const res = await api.patch("/admin/advertisements/bulk-restore", { ids }); setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id))); - toast.success(`${ids.length} advertisement(s) restored.`); + toast(`${ids.length} advertisement(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -185,7 +220,12 @@ export function AdvertisementsProvider({ children }) { request(async () => { const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`); setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId)); - toast.success("Advertisement permanently deleted."); + toast("Advertisement permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -197,7 +237,12 @@ export function AdvertisementsProvider({ children }) { request(async () => { const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } }); setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id))); - toast.success(`${ids.length} advertisement(s) permanently deleted.`); + toast(`${ids.length} advertisement(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 982ca15..3b7affd 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -69,7 +69,12 @@ export function AssetsProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -201,7 +206,12 @@ export function AssetsProvider({ children }) { if (asset) { setAssets((prev) => [asset, ...prev]); invalidateListCache(); - toast.success("Asset uploaded successfully."); + toast("Asset uploaded successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -227,7 +237,12 @@ export function AssetsProvider({ children }) { setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); setSelectedAsset(asset); invalidateListCache(); - toast.success("Asset updated successfully."); + toast("Asset updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -244,7 +259,12 @@ export function AssetsProvider({ children }) { setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); invalidateListCache(); - toast.success("Asset archived."); + toast("Asset archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -259,7 +279,12 @@ export function AssetsProvider({ children }) { }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); - toast.success(`${ids.length} asset(s) archived.`); + toast(`${ids.length} asset(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -274,7 +299,12 @@ export function AssetsProvider({ children }) { if (asset) { setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); invalidateListCache(); - toast.success("Asset restored."); + toast("Asset restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -288,7 +318,12 @@ export function AssetsProvider({ children }) { const res = await api.patch("/admin/assets/bulk-restore", { ids }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); - toast.success(`${ids.length} asset(s) restored.`); + toast(`${ids.length} asset(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -302,7 +337,12 @@ export function AssetsProvider({ children }) { setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); invalidateListCache(); - toast.success("Asset permanently deleted."); + toast("Asset permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -317,7 +357,12 @@ export function AssetsProvider({ children }) { }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); - toast.success(`${ids.length} asset(s) permanently deleted.`); + toast(`${ids.length} asset(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] diff --git a/src/contexts/AdminCategoriesContext.jsx b/src/contexts/AdminCategoriesContext.jsx index 501ab34..5f54c9d 100644 --- a/src/contexts/AdminCategoriesContext.jsx +++ b/src/contexts/AdminCategoriesContext.jsx @@ -13,7 +13,12 @@ export function AdminCategoriesProvider({ children }) { setLoading(true); try { return await fn(); } catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); + toast(err?.response?.data?.message ?? "Something went wrong.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -32,28 +37,48 @@ export function AdminCategoriesProvider({ children }) { const createCategory = useCallback((payload) => wrap(async () => { const { data } = await api.post("/admin/categories", payload); - toast.success("Category created."); + toast("Category created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [wrap]); const updateCategory = useCallback((id, payload) => wrap(async () => { const { data } = await api.put(`/admin/categories/${id}`, payload); setCategory(data.data ?? null); - toast.success("Category updated."); + toast("Category updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [wrap]); const archiveCategory = useCallback((id) => wrap(async () => { await api.delete(`/admin/categories/${id}`); setCategories((prev) => prev.filter((c) => c.id !== id)); - toast.success("Category archived."); + toast("Category archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [wrap]); const restoreCategory = useCallback((id) => wrap(async () => { await api.post(`/admin/categories/${id}/restore`); setCategories((prev) => prev.filter((c) => c.id !== id)); - toast.success("Category restored."); + toast("Category restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [wrap]); diff --git a/src/contexts/AdminCourseReadingProgressContext.jsx b/src/contexts/AdminCourseReadingProgressContext.jsx index 4944cc8..1785c59 100644 --- a/src/contexts/AdminCourseReadingProgressContext.jsx +++ b/src/contexts/AdminCourseReadingProgressContext.jsx @@ -43,7 +43,12 @@ export function AdminCourseReadingProgressProvider({ children }) { setProgressList(data.data ?? []); setDetailCache({}); } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not load reading progress.'); + toast(err?.response?.data?.message ?? 'Could not load reading progress.', { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setListLoading(false); } @@ -60,7 +65,12 @@ export function AdminCourseReadingProgressProvider({ children }) { setDetailCache((prev) => ({ ...prev, [userId]: breakdown })); return breakdown; } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not load user progress.'); + toast(err?.response?.data?.message ?? 'Could not load user progress.', { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setDetailLoading(false); diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index 842c86f..236a906 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -52,8 +52,18 @@ export function CoursesProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - if (err.status === 404 && message) toast.warning(message); - else toast.error(message); + if (err.status === 404 && message) toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); + else toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -111,7 +121,12 @@ export function CoursesProvider({ children }) { const course = data?.data?.data ?? null; if (course) { setCourses((prev) => [course, ...prev]); - toast.success("Course created successfully."); + toast("Course created successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -126,7 +141,12 @@ export function CoursesProvider({ children }) { if (course) { setCourses((prev) => prev.map((c) => (c.course_id === courseId ? course : c))); setCourse(course); - toast.success("Course updated successfully."); + toast("Course updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -139,7 +159,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}`); setCourses((prev) => prev.filter((c) => c.course_id !== courseId)); setCourse((prev) => (prev?.course_id === courseId ? null : prev)); - toast.success("Course archived."); + toast("Course archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -150,7 +175,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/bulk`, { data: { ids } }); setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id))); - toast.success("Courses archived."); + toast("Courses archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -181,7 +211,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setCourses((prev) => prev.filter((c) => c.course_id !== courseId)); - toast.success("Course restored."); + toast("Course restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -193,7 +228,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.patch(`${BASE}/restore/bulk`, { ids }); setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id))); - toast.success("Courses restored."); + toast("Courses restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -205,7 +245,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/permanent`); setCourses((prev) => prev.filter((c) => c.course_id !== courseId)); setCourse((prev) => (prev?.course_id === courseId ? null : prev)); - toast.success("Course permanently deleted."); + toast("Course permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -216,7 +261,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/bulk/permanent`, { data: { ids } }); setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id))); - toast.success("Courses permanently deleted."); + toast("Courses permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -255,7 +305,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.put(`${BASE}/${courseId}/prerequisites`, { prerequisites }); setPrerequisites(prerequisites); - toast.success("Prerequisites updated."); + toast("Prerequisites updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -305,7 +360,12 @@ export function CoursesProvider({ children }) { const unit = data?.data?.data ?? null; if (unit) { setUnits((prev) => [...prev, unit]); - toast.success("Unit created successfully."); + toast("Unit created successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -320,7 +380,12 @@ export function CoursesProvider({ children }) { if (unit) { setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u))); setUnit(unit); - toast.success("Unit updated successfully."); + toast("Unit updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -333,7 +398,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`); setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); setUnit((prev) => (prev?.unit_id === unitId ? null : prev)); - toast.success("Unit archived."); + toast("Unit archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -344,7 +414,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/bulk`, { data: { ids } }); setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id))); - toast.success("Units archived."); + toast("Units archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -376,7 +451,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); - toast.success("Unit restored."); + toast("Unit restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -388,7 +468,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/units/restore/bulk`, { ids } ); setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id))); - toast.success("Units restored."); + toast("Units restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -400,7 +485,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/permanent`); setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); setUnit((prev) => (prev?.unit_id === unitId ? null : prev)); - toast.success("Unit permanently deleted."); + toast("Unit permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -411,7 +501,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/bulk/permanent`, { data: { ids } }); setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id))); - toast.success("Units permanently deleted."); + toast("Units permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -450,7 +545,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuiz(result); - toast.success("Quiz created."); + toast("Quiz created.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -464,7 +564,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuiz(result); - toast.success("Quiz updated."); + toast("Quiz updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -477,7 +582,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}`, { data: { deletedBy } }); setQuiz(null); setQuestions([]); - toast.success("Quiz archived."); + toast("Quiz archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -503,7 +613,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuiz(result); - toast.success("Quiz restored."); + toast("Quiz restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -532,7 +647,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => [...prev, result]); - toast.success("Question added."); + toast("Question added.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -546,7 +666,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q))); - toast.success("Question updated."); + toast("Question updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -559,7 +684,12 @@ export function CoursesProvider({ children }) { const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy }); const result = data?.data?.data ?? []; setQuestions(result); - toast.success("Quiz saved."); + toast("Quiz saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -570,7 +700,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}`, { data: { deletedBy } }); setQuestions((prev) => prev.filter((q) => q.question_id !== questionId)); - toast.success("Question archived."); + toast("Question archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -581,7 +716,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk`, { data: { ids, deletedBy } }); setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id))); - toast.success("Questions archived."); + toast("Questions archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -602,7 +742,12 @@ export function CoursesProvider({ children }) { (courseId, unitId, quizId, questionId, restoredBy) => request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}/restore`, { restoredBy }); - toast.success("Question restored."); + toast("Question restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -612,7 +757,12 @@ export function CoursesProvider({ children }) { (courseId, unitId, quizId, ids, restoredBy) => request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/restore/bulk`, { ids, restoredBy }); - toast.success("Questions restored."); + toast("Questions restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -647,7 +797,12 @@ export function CoursesProvider({ children }) { const lesson = data?.data?.data ?? null; if (lesson) { setLessons((prev) => [...prev, lesson]); - toast.success("Lesson created successfully."); + toast("Lesson created successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -662,7 +817,12 @@ export function CoursesProvider({ children }) { if (lesson) { setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l))); setLesson(lesson); - toast.success("Lesson updated successfully."); + toast("Lesson updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -675,7 +835,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`); setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev)); - toast.success("Lesson archived."); + toast("Lesson archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -686,7 +851,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk`, { data: { ids } }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); - toast.success("Lessons archived."); + toast("Lessons archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -718,7 +888,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); - toast.success("Lesson restored."); + toast("Lesson restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -730,7 +905,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/lessons/restore/bulk`, { ids }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); - toast.success("Lessons restored."); + toast("Lessons restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -742,7 +922,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/permanent`); setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev)); - toast.success("Lesson permanently deleted."); + toast("Lesson permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -753,7 +938,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk/permanent`, { data: { ids } }); setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); - toast.success("Lessons permanently deleted."); + toast("Lessons permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -781,7 +971,12 @@ export function CoursesProvider({ children }) { const page = data?.data?.data ?? null; if (page) { setLessonPage(page); - toast.success("Lesson page saved."); + toast("Lesson page saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -811,7 +1006,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setAssessment(result); - toast.success("Assessment created."); + toast("Assessment created.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -825,7 +1025,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setAssessment(result); - toast.success("Assessment updated."); + toast("Assessment updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -838,7 +1043,12 @@ export function CoursesProvider({ children }) { const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}`, { data: { deletedBy } }); setAssessment(null); setQuestions([]); - toast.success("Assessment archived."); + toast("Assessment archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -864,7 +1074,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setAssessment(result); - toast.success("Assessment restored."); + toast("Assessment restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -893,7 +1108,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => [...prev, result]); - toast.success("Question added."); + toast("Question added.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -907,7 +1127,12 @@ export function CoursesProvider({ children }) { const result = data?.data?.data ?? null; if (result) { setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q))); - toast.success("Question updated."); + toast("Question updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return data; }), @@ -920,7 +1145,12 @@ export function CoursesProvider({ children }) { const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy }); const result = data?.data?.data ?? []; setQuestions(result); - toast.success("Assessment saved."); + toast("Assessment saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -931,7 +1161,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}`, { data: { deletedBy } }); setQuestions((prev) => prev.filter((q) => q.question_id !== questionId)); - toast.success("Question archived."); + toast("Question archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -942,7 +1177,12 @@ export function CoursesProvider({ children }) { request(async () => { const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk`, { data: { ids, deletedBy } }); setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id))); - toast.success("Questions archived."); + toast("Questions archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -963,7 +1203,12 @@ export function CoursesProvider({ children }) { (courseId, assessmentId, questionId, restoredBy) => request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}/restore`, { restoredBy }); - toast.success("Question restored."); + toast("Question restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -973,7 +1218,12 @@ export function CoursesProvider({ children }) { (courseId, assessmentId, ids, restoredBy) => request(async () => { const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/restore/bulk`, { ids, restoredBy }); - toast.success("Questions restored."); + toast("Questions restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data; }), [request], @@ -1030,7 +1280,12 @@ export function CoursesProvider({ children }) { const saveCourseProduct = useCallback( (courseId, payload) => request(async () => { const { data } = await api.put(`/admin/products/courses/${courseId}/product`, payload); - toast.success("Product listing saved."); + toast("Product listing saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data ?? null; }), [request], ); @@ -1038,7 +1293,12 @@ export function CoursesProvider({ children }) { const removeCourseProduct = useCallback( (courseId) => request(async () => { await api.delete(`/admin/products/courses/${courseId}/product`); - toast.success("Product listing removed."); + toast("Product listing removed.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request], ); @@ -1053,7 +1313,12 @@ export function CoursesProvider({ children }) { const syncCourseCategories = useCallback( (courseId, categoryIds) => request(async () => { const { data } = await api.post(`/admin/products/courses/${courseId}/categories`, { category_ids: categoryIds }); - toast.success("Categories updated."); + toast("Categories updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data ?? []; }), [request], ); @@ -1068,7 +1333,12 @@ export function CoursesProvider({ children }) { const syncInstructors = useCallback( (courseId, instructors) => request(async () => { await api.put(`${BASE}/${courseId}/instructors`, { instructors }); - toast.success("Instructors updated."); + toast("Instructors updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); }), [request], ); @@ -1082,7 +1352,12 @@ export function CoursesProvider({ children }) { const syncCourseAchievements = useCallback( (courseId, achievement_keys) => request(async () => { await api.put(`${BASE}/${courseId}/achievements`, { achievement_keys }); - toast.success("Rewards updated."); + toast("Rewards updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); }), [request], ); diff --git a/src/contexts/AdminDashboardContext.jsx b/src/contexts/AdminDashboardContext.jsx index 4f8b72a..efdabc6 100644 --- a/src/contexts/AdminDashboardContext.jsx +++ b/src/contexts/AdminDashboardContext.jsx @@ -21,7 +21,12 @@ export function AdminDashboardProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); diff --git a/src/contexts/AdminNotificationBroadcastContext.jsx b/src/contexts/AdminNotificationBroadcastContext.jsx index 30ad1db..12df74d 100644 --- a/src/contexts/AdminNotificationBroadcastContext.jsx +++ b/src/contexts/AdminNotificationBroadcastContext.jsx @@ -34,7 +34,12 @@ export function NotificationBroadcastsProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -100,7 +105,12 @@ export function NotificationBroadcastsProvider({ children }) { const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => [broadcast, ...prev]); - toast.success("Notification broadcast created."); + toast("Notification broadcast created.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -116,7 +126,12 @@ export function NotificationBroadcastsProvider({ children }) { if (broadcast) { setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b))); setSelectedBroadcast(broadcast); - toast.success("Notification broadcast updated."); + toast("Notification broadcast updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -132,7 +147,12 @@ export function NotificationBroadcastsProvider({ children }) { if (broadcast) { setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b))); setSelectedBroadcast(broadcast); - toast.success("Notification broadcast sent."); + toast("Notification broadcast sent.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -148,7 +168,12 @@ export function NotificationBroadcastsProvider({ children }) { }); setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev)); - toast.success("Notification broadcast archived."); + toast("Notification broadcast archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -162,7 +187,12 @@ export function NotificationBroadcastsProvider({ children }) { data: { ids, deletedBy }, }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); - toast.success(`${ids.length} notification broadcast(s) archived.`); + toast(`${ids.length} notification broadcast(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -176,7 +206,12 @@ export function NotificationBroadcastsProvider({ children }) { const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); - toast.success("Notification broadcast restored."); + toast("Notification broadcast restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -189,7 +224,12 @@ export function NotificationBroadcastsProvider({ children }) { request(async () => { const res = await api.patch("/admin/notification-broadcasts/bulk-restore", { ids }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); - toast.success(`${ids.length} notification broadcast(s) restored.`); + toast(`${ids.length} notification broadcast(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -201,7 +241,12 @@ export function NotificationBroadcastsProvider({ children }) { request(async () => { const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}/permanent`); setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); - toast.success("Notification broadcast permanently deleted."); + toast("Notification broadcast permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -213,7 +258,12 @@ export function NotificationBroadcastsProvider({ children }) { request(async () => { const res = await api.delete("/admin/notification-broadcasts/bulk/permanent", { data: { ids } }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); - toast.success(`${ids.length} notification broadcast(s) permanently deleted.`); + toast(`${ids.length} notification broadcast(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] diff --git a/src/contexts/AdminNotificationTemplateContext.jsx b/src/contexts/AdminNotificationTemplateContext.jsx index e7be244..b13efc8 100644 --- a/src/contexts/AdminNotificationTemplateContext.jsx +++ b/src/contexts/AdminNotificationTemplateContext.jsx @@ -19,7 +19,12 @@ export function AdminNotificationTemplateProvider({ children }) { setLoading(true); try { return await fn(); } catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); + toast(err?.response?.data?.message ?? "Something went wrong.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -45,7 +50,12 @@ export function AdminNotificationTemplateProvider({ children }) { prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t)) ); if (template && String(template.notification_template_id) === String(id)) setTemplate(data.data); - toast.success("Notification template updated."); + toast("Notification template updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request, template]); diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index 8951fce..848b77f 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -57,7 +57,12 @@ export function AdminTaskProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -70,7 +75,12 @@ export function AdminTaskProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setCompletionLoading(false); @@ -133,7 +143,12 @@ export function AdminTaskProvider({ children }) { (payload) => request(async () => { const res = await api.post(BASE, payload); - toast.success('Task list created.'); + toast('Task list created.', { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data?.data ?? null; }), [request] @@ -143,7 +158,12 @@ export function AdminTaskProvider({ children }) { (taskListId, payload) => request(async () => { const res = await api.patch(`${BASE}/${taskListId}`, payload); - toast.success('Task list updated.'); + toast('Task list updated.', { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data?.data ?? null; }), [request] @@ -153,7 +173,12 @@ export function AdminTaskProvider({ children }) { (taskListId) => request(async () => { await api.delete(`${BASE}/${taskListId}`); - toast.success('Task list archived.'); + toast('Task list archived.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -163,7 +188,12 @@ export function AdminTaskProvider({ children }) { (taskListId) => request(async () => { await api.patch(`${BASE}/${taskListId}/restore`); - toast.success('Task list restored.'); + toast('Task list restored.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -173,7 +203,12 @@ export function AdminTaskProvider({ children }) { (ids) => request(async () => { await api.post(`${BASE}/bulk-archive`, { ids }); - toast.success(`${ids.length} task list(s) archived.`); + toast(`${ids.length} task list(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -183,7 +218,12 @@ export function AdminTaskProvider({ children }) { (ids) => request(async () => { await api.post(`${BASE}/bulk-restore`, { ids }); - toast.success(`${ids.length} task list(s) restored.`); + toast(`${ids.length} task list(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -193,7 +233,12 @@ export function AdminTaskProvider({ children }) { (taskListId) => request(async () => { await api.delete(`${BASE}/${taskListId}/permanent`); - toast.success('Task list permanently deleted.'); + toast('Task list permanently deleted.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -203,7 +248,12 @@ export function AdminTaskProvider({ children }) { (ids) => request(async () => { await api.post(`${BASE}/bulk-delete`, { ids }); - toast.success(`${ids.length} task list(s) permanently deleted.`); + toast(`${ids.length} task list(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -254,9 +304,19 @@ export function AdminTaskProvider({ children }) { }); const result = res.data?.data ?? {}; if (result.assigned_ids?.length) { - toast.success(`${result.assigned_ids.length} group(s) assigned.`); + toast(`${result.assigned_ids.length} group(s) assigned.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } else { - toast.info('All selected groups were already assigned.'); + toast('All selected groups were already assigned.', { + action: { + label: "Close", + onClick: () => {} + } + }); } return result; }), @@ -270,7 +330,12 @@ export function AdminTaskProvider({ children }) { group_ids: groupIds, }); const result = res.data?.data ?? {}; - toast.success(`${result.unassigned_ids?.length ?? 0} group(s) unassigned.`); + toast(`${result.unassigned_ids?.length ?? 0} group(s) unassigned.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return result; }), [request] @@ -331,7 +396,12 @@ export function AdminTaskProvider({ children }) { (taskListId, payload) => request(async () => { const res = await api.post(`${BASE}/${taskListId}/tasks`, payload); - toast.success('Task created.'); + toast('Task created.', { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data?.data?.data ?? null; }), [request] @@ -341,7 +411,12 @@ export function AdminTaskProvider({ children }) { (taskListId, taskId, payload) => request(async () => { const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload); - toast.success('Task updated.'); + toast('Task updated.', { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data?.data?.data ?? null; }), [request] @@ -351,7 +426,12 @@ export function AdminTaskProvider({ children }) { (taskListId, taskId) => request(async () => { await api.delete(`${BASE}/${taskListId}/tasks/${taskId}`); - toast.success('Task archived.'); + toast('Task archived.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -361,7 +441,12 @@ export function AdminTaskProvider({ children }) { (taskListId, taskId) => request(async () => { await api.patch(`${BASE}/${taskListId}/tasks/${taskId}/restore`); - toast.success('Task restored.'); + toast('Task restored.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -371,7 +456,12 @@ export function AdminTaskProvider({ children }) { (taskListId, ids) => request(async () => { await api.post(`${BASE}/${taskListId}/tasks/bulk-archive`, { ids }); - toast.success(`${ids.length} task(s) archived.`); + toast(`${ids.length} task(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -381,7 +471,12 @@ export function AdminTaskProvider({ children }) { (taskListId, ids) => request(async () => { await api.post(`${BASE}/${taskListId}/tasks/bulk-restore`, { ids }); - toast.success(`${ids.length} task(s) restored.`); + toast(`${ids.length} task(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -391,7 +486,12 @@ export function AdminTaskProvider({ children }) { (taskListId, taskId) => request(async () => { await api.delete(`${BASE}/${taskListId}/tasks/${taskId}/permanent`); - toast.success('Task permanently deleted.'); + toast('Task permanently deleted.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -401,7 +501,12 @@ export function AdminTaskProvider({ children }) { (taskListId, ids) => request(async () => { await api.post(`${BASE}/${taskListId}/tasks/bulk-delete`, { ids }); - toast.success(`${ids.length} task(s) permanently deleted.`); + toast(`${ids.length} task(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request] @@ -520,7 +625,12 @@ export function AdminTaskProvider({ children }) { await api.delete( `${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}` ); - toast.success('Completion archived.'); + toast('Completion archived.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [completionRequest] @@ -533,7 +643,12 @@ export function AdminTaskProvider({ children }) { await api.patch( `${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}/restore` ); - toast.success('Completion restored.'); + toast('Completion restored.', { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [completionRequest] @@ -547,7 +662,12 @@ export function AdminTaskProvider({ children }) { `${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-archive`, { ids } ); - toast.success(`${ids.length} completion(s) archived.`); + toast(`${ids.length} completion(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [completionRequest] @@ -561,7 +681,12 @@ export function AdminTaskProvider({ children }) { `${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-restore`, { ids } ); - toast.success(`${ids.length} completion(s) restored.`); + toast(`${ids.length} completion(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [completionRequest] diff --git a/src/contexts/AdminTierCategoriesContext.jsx b/src/contexts/AdminTierCategoriesContext.jsx index 804a637..cd72b4a 100644 --- a/src/contexts/AdminTierCategoriesContext.jsx +++ b/src/contexts/AdminTierCategoriesContext.jsx @@ -19,7 +19,12 @@ export function AdminTierCategoriesProvider({ children }) { setLoading(true); try { return await fn(); } catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); + toast(err?.response?.data?.message ?? "Something went wrong.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -41,7 +46,12 @@ export function AdminTierCategoriesProvider({ children }) { const createCategory = useCallback((payload) => request(async () => { const { data } = await api.post("/admin/tiers/categories", payload); - toast.success("Tier category created."); + toast("Tier category created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request]); @@ -52,7 +62,12 @@ export function AdminTierCategoriesProvider({ children }) { prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c)) ); if (category && String(category.tier_category_id) === String(id)) setCategory(data.data); - toast.success("Tier category updated."); + toast("Tier category updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request, category]); @@ -60,7 +75,12 @@ export function AdminTierCategoriesProvider({ children }) { request(async () => { await api.delete(`/admin/tiers/categories/${id}`); setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id))); - toast.success("Tier category deleted."); + toast("Tier category deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; }), [request]); diff --git a/src/contexts/AdminTierPoliciesContext.jsx b/src/contexts/AdminTierPoliciesContext.jsx index 0a73952..f338564 100644 --- a/src/contexts/AdminTierPoliciesContext.jsx +++ b/src/contexts/AdminTierPoliciesContext.jsx @@ -20,7 +20,12 @@ export function AdminTierPoliciesProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -45,7 +50,12 @@ export function AdminTierPoliciesProvider({ children }) { ? prev.map((b) => (b.key === key ? data.data : b)) : [...prev, data.data]; }); - toast.success("Badge saved."); + toast("Badge saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; }), [request]); diff --git a/src/contexts/AdminTiersContext.jsx b/src/contexts/AdminTiersContext.jsx index b1e0192..6a33ca4 100644 --- a/src/contexts/AdminTiersContext.jsx +++ b/src/contexts/AdminTiersContext.jsx @@ -33,7 +33,12 @@ export function AdminTiersProvider({ children }) { totalPages: data.data?.pagination?.totalPages ?? 1, totalRecords: data.data?.pagination?.totalRecords ?? 0, }); - } catch { toast.error("Could not load plans."); } + } catch { toast("Could not load plans.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } }, []); @@ -42,7 +47,12 @@ export function AdminTiersProvider({ children }) { try { const { data } = await api.get(`/admin/tiers/${id}`); setPlan(data.data ?? null); - } catch { toast.error("Could not load plan."); } + } catch { toast("Could not load plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } }, []); @@ -50,10 +60,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { const { data } = await api.post("/admin/tiers", payload); - toast.success("Plan created."); + toast("Plan created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not create plan."); + toast(err?.response?.data?.message ?? "Could not create plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -62,10 +82,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { const { data } = await api.put(`/admin/tiers/${id}`, payload); - toast.success("Plan updated."); + toast("Plan updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not update plan."); + toast(err?.response?.data?.message ?? "Could not update plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); } }, []); @@ -74,10 +104,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.delete(`/admin/tiers/${id}`); - toast.success("Plan archived."); + toast("Plan archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not archive plan."); + toast(err?.response?.data?.message ?? "Could not archive plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -86,10 +126,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.post(`/admin/tiers/${id}/restore`); - toast.success("Plan restored."); + toast("Plan restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not restore plan."); + toast(err?.response?.data?.message ?? "Could not restore plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -98,10 +148,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.post("/admin/tiers/bulk/archive", { ids }); - toast.success("Plans archived."); + toast("Plans archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not archive plans."); + toast(err?.response?.data?.message ?? "Could not archive plans.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -110,10 +170,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.post("/admin/tiers/bulk/restore", { ids }); - toast.success("Plans restored."); + toast("Plans restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not restore plans."); + toast(err?.response?.data?.message ?? "Could not restore plans.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -122,10 +192,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.delete(`/admin/tiers/${id}/permanent`); - toast.success("Plan permanently deleted."); + toast("Plan permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not permanently delete plan."); + toast(err?.response?.data?.message ?? "Could not permanently delete plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -134,10 +214,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.post("/admin/tiers/bulk/permanent-delete", { ids }); - toast.success("Plans permanently deleted."); + toast("Plans permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not permanently delete plans."); + toast(err?.response?.data?.message ?? "Could not permanently delete plans.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -162,7 +252,12 @@ export function AdminTiersProvider({ children }) { try { const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`); setUserTiers(data.data ?? []); - } catch { toast.error("Could not load user tiers."); } + } catch { toast("Could not load user tiers.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } }, []); @@ -170,10 +265,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.post("/admin/tiers/users/tiers/grant", payload); - toast.success("Tier granted."); + toast("Tier granted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not grant tier."); + toast(err?.response?.data?.message ?? "Could not grant tier.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -182,10 +287,20 @@ export function AdminTiersProvider({ children }) { setLoading(true); try { await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`); - toast.success("Tier revoked."); + toast("Tier revoked.", { + action: { + label: "Close", + onClick: () => {} + } + }); return true; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not revoke tier."); + toast(err?.response?.data?.message ?? "Could not revoke tier.", { + action: { + label: "Close", + onClick: () => {} + } + }); return false; } finally { setLoading(false); } }, []); @@ -206,7 +321,12 @@ export function AdminTiersProvider({ children }) { totalPages: data.data?.pagination?.totalPages ?? 1, totalRecords: data.data?.pagination?.totalRecords ?? 0, }); - } catch { toast.error("Could not load payments."); } + } catch { toast("Could not load payments.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } }, []); @@ -215,7 +335,12 @@ export function AdminTiersProvider({ children }) { try { const { data } = await api.get(`/admin/tiers/payments/${id}`); setPayment(data.data ?? null); - } catch { toast.error("Could not load payment."); } + } catch { toast("Could not load payment.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } }, []); diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index ec22f0f..303bf3b 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -45,7 +45,12 @@ export const UserProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -108,7 +113,12 @@ export const UserProvider = ({ children }) => { (payload) => request(async () => { const res = await api.post(`${BASE}/users/staff`, payload); - toast.success("Staff user added successfully."); + toast("Staff user added successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -122,7 +132,12 @@ export const UserProvider = ({ children }) => { setUsers((prev) => prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u)) ); - toast.success("User updated successfully."); + toast("User updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -136,7 +151,12 @@ export const UserProvider = ({ children }) => { setUsers((prev) => prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u)) ); - toast.success("User deactivated."); + toast("User deactivated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -154,7 +174,12 @@ export const UserProvider = ({ children }) => { deactivated_ids.includes(u.user_id) ? { ...u, is_active: false } : u ) ); - toast.success(`${deactivated_ids.length} user(s) deactivated.`); + toast(`${deactivated_ids.length} user(s) deactivated.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -169,7 +194,12 @@ export const UserProvider = ({ children }) => { setUsers((prev) => prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u)) ); - toast.success("User restored."); + toast("User restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -187,7 +217,12 @@ export const UserProvider = ({ children }) => { restored_ids.includes(u.user_id) ? { ...u, is_active: true } : u ) ); - toast.success(`${restored_ids.length} user(s) restored.`); + toast(`${restored_ids.length} user(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -200,7 +235,12 @@ export const UserProvider = ({ children }) => { request(async () => { const res = await api.delete(`${BASE}/users/${userId}/permanent`); setUsers((prev) => prev.filter((u) => u.user_id !== userId)); - toast.success("User permanently deleted."); + toast("User permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -214,7 +254,12 @@ export const UserProvider = ({ children }) => { const { deleted_ids } = res.data?.data ?? {}; if (deleted_ids?.length) { setUsers((prev) => prev.filter((u) => !deleted_ids.includes(u.user_id))); - toast.success(`${deleted_ids.length} user(s) permanently deleted.`); + toast(`${deleted_ids.length} user(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -240,7 +285,12 @@ export const UserProvider = ({ children }) => { setSessions((prev) => prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s)) ); - toast.success("Session terminated."); + toast("Session terminated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -284,7 +334,12 @@ export const UserProvider = ({ children }) => { }); return d; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load activity."); + toast(err?.response?.data?.message ?? "Could not load activity.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setActivityLoading(false); @@ -311,7 +366,12 @@ export const UserProvider = ({ children }) => { setAchievements(res.data?.data ?? []); return res.data?.data; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load achievements."); + toast(err?.response?.data?.message ?? "Could not load achievements.", { + action: { + label: "Close", + onClick: () => {} + } + }); return []; } finally { setAchievementsLoading(false); @@ -327,7 +387,12 @@ export const UserProvider = ({ children }) => { prev.map((u) => (u.user_id === userId ? { ...u, is_banned: true } : u)) ); if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: true } : prev); - toast.success("User banned successfully."); + toast("User banned successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request, user] @@ -342,7 +407,12 @@ export const UserProvider = ({ children }) => { prev.map((u) => (u.user_id === userId ? { ...u, is_banned: false } : u)) ); if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: false } : prev); - toast.success("User unbanned successfully."); + toast("User unbanned successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request, user] @@ -358,7 +428,12 @@ export const UserProvider = ({ children }) => { setUsers((prev) => prev.map((u) => (banned_ids.includes(u.user_id) ? { ...u, is_banned: true } : u)) ); - toast.success(`${banned_ids.length} user(s) banned.`); + toast(`${banned_ids.length} user(s) banned.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -375,7 +450,12 @@ export const UserProvider = ({ children }) => { setUsers((prev) => prev.map((u) => (unbanned_ids.includes(u.user_id) ? { ...u, is_banned: false } : u)) ); - toast.success(`${unbanned_ids.length} user(s) unbanned.`); + toast(`${unbanned_ids.length} user(s) unbanned.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -390,7 +470,12 @@ export const UserProvider = ({ children }) => { setBans(res.data?.data ?? []); return res.data?.data; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load ban history."); + toast(err?.response?.data?.message ?? "Could not load ban history.", { + action: { + label: "Close", + onClick: () => {} + } + }); return []; } finally { setBansLoading(false); diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx index bd69a56..7267595 100644 --- a/src/contexts/AdminUserGroupContext.jsx +++ b/src/contexts/AdminUserGroupContext.jsx @@ -27,7 +27,12 @@ export function UserGroupProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -121,7 +126,12 @@ export function UserGroupProvider({ children }) { request(async () => { const res = await api.post(`${BASE}/groups`, { name, description, group_code }); setGroups((prev) => [res.data?.data, ...prev]); - toast.success("Group created successfully."); + toast("Group created successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -136,7 +146,12 @@ export function UserGroupProvider({ children }) { prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g)) ); setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev)); - toast.success("Group updated successfully."); + toast("Group updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -150,7 +165,12 @@ export function UserGroupProvider({ children }) { setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g)) ); - toast.success("Group deactivated."); + toast("Group deactivated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -168,7 +188,12 @@ export function UserGroupProvider({ children }) { deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g ) ); - toast.success(`${deactivated_ids.length} group(s) deactivated.`); + toast(`${deactivated_ids.length} group(s) deactivated.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -183,7 +208,12 @@ export function UserGroupProvider({ children }) { setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g)) ); - toast.success("Group restored."); + toast("Group restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -201,7 +231,12 @@ export function UserGroupProvider({ children }) { restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g ) ); - toast.success(`${restored_ids.length} group(s) restored.`); + toast(`${restored_ids.length} group(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -214,7 +249,12 @@ export function UserGroupProvider({ children }) { request(async () => { const res = await api.delete(`${BASE}/groups/${gid}/permanent`); setGroups((prev) => prev.filter((g) => g.group_id !== gid)); - toast.success("Group permanently deleted."); + toast("Group permanently deleted.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -228,7 +268,12 @@ export function UserGroupProvider({ children }) { const { deleted_ids } = res.data?.data ?? {}; if (deleted_ids?.length) { setGroups((prev) => prev.filter((g) => !deleted_ids.includes(g.group_id))); - toast.success(`${deleted_ids.length} group(s) permanently deleted.`); + toast(`${deleted_ids.length} group(s) permanently deleted.`, { + action: { + label: "Close", + onClick: () => {} + } + }); } return res.data; }), @@ -241,7 +286,12 @@ export function UserGroupProvider({ children }) { request(async () => { const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids }); setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); - toast.success("Users added to group."); + toast("Users added to group.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -254,7 +304,12 @@ export function UserGroupProvider({ children }) { const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } }); setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); - toast.success("Users removed from group."); + toast("Users removed from group.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 2bd58f3..28db510 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -22,22 +22,35 @@ export function AuthProvider({ children }) { _setAccessToken(token) }, []) + // Shared by verifyOTP, restoreSession, and login's trusted-device fast path + // — anywhere the backend hands back a fully-authenticated session in one shot. + const applySession = useCallback((data) => { + setAccessToken(data.accessToken) + setUser(data.user) + setSessionId(data.session_id ?? null) + }, [setAccessToken]) + // ── Login ────────────────────────────────────────────────────────────────── + // Credentials get you an OTP — unless this device already cleared one + // recently and its trust window is still valid, in which case the backend + // returns otpRequired:false along with a full session, same shape as + // verifyOTP's response. const login = useCallback(async ({ email, password }) => { setAuthError(null) try { const { data } = await api.post('/auth/login', { email, password }) - setAccessToken(data.data.accessToken) - setUser(data.data.user) - setSessionId(data.data.session_id ?? null) - return { success: true, user: data.data.user } + if (data.data.otpRequired === false) { + applySession(data.data) + return { success: true, otpRequired: false, user: data.data.user } + } + return { success: true, otpRequired: true, email: data.data.email } } catch (err) { const message = err.response?.data?.message || 'Login failed. Please try again.' const errors = err.response?.data?.errors ?? null setAuthError(message) return { success: false, message, errors } } - }, []) + }, [applySession]) // ── Register ─────────────────────────────────────────────────────────────── const register = useCallback(async (payload) => { @@ -57,16 +70,14 @@ export function AuthProvider({ children }) { setAuthError(null) try { const { data } = await api.post('/auth/verify-otp', { email, otp }) - setAccessToken(data.data.accessToken) - setUser(data.data.user) - setSessionId(data.data.session_id ?? null) + applySession(data.data) return { success: true, user: data.data.user } } catch (err) { const message = err.response?.data?.message || 'OTP verification failed.' setAuthError(message) return { success: false, message } } - }, []) + }, [applySession]) // ── Resend OTP ───────────────────────────────────────────────────────────── const resendOTP = useCallback(async ({ email }) => { @@ -79,6 +90,28 @@ export function AuthProvider({ children }) { } }, []) + // ── Forgot password (request OTP) ───────────────────────────────────────── + const forgotPassword = useCallback(async ({ email }) => { + try { + const { data } = await api.post('/auth/forgot-password', { email }) + return { success: true, email: data.data.email } + } catch (err) { + const message = err.response?.data?.message || 'Could not process request.' + return { success: false, message } + } + }, []) + + // ── Reset password (OTP + new password in one step) ─────────────────────── + const resetPassword = useCallback(async ({ email, otp, new_password }) => { + try { + await api.post('/auth/reset-password', { email, otp, new_password }) + return { success: true } + } catch (err) { + const message = err.response?.data?.message || 'Password reset failed.' + return { success: false, message } + } + }, []) + // ── Logout ───────────────────────────────────────────────────────────────── const logout = useCallback(async () => { try { @@ -94,23 +127,23 @@ export function AuthProvider({ children }) { // ── Restore session ──────────────────────────────────────────────────────── const restoreSession = useCallback(async () => { - if (isRestoring.current) return + if (isRestoring.current) return { success: false } isRestoring.current = true try { - if (accessTokenRef.current) return + if (accessTokenRef.current) return { success: true } const { data } = await api.post('/auth/refresh') - setAccessToken(data.data.accessToken) - setUser(data.data.user) - setSessionId(data.data.session_id ?? null) + applySession(data.data) + return { success: true, user: data.data.user } } catch (_) { setAccessToken(null) setUser(null) setSessionId(null) + return { success: false } } finally { setLoading(false) } - }, []) + }, [applySession]) return ( { + setListLoading((prev) => ({ ...prev, [placement]: true })); + try { + const [currentProfile, { data }] = await Promise.all([ + ensureProfile(), + api.get("/client/advertisements/active-list", { params: { placement, limit } }), + ]); + const raw = data?.data?.data ?? []; + const list = raw + .map((ad) => resolveVisibility(currentProfile, ad)) + .filter(Boolean); + setAdLists((prev) => ({ ...prev, [placement]: list })); + return list; + } catch { + setAdLists((prev) => ({ ...prev, [placement]: [] })); + return []; + } finally { + setListLoading((prev) => ({ ...prev, [placement]: false })); + } + }, + [ensureProfile] + ); + // ─── POST /api/client/advertisements/:advertisementId/click ─────────────── // Fire-and-forget — never await this on a navigation-blocking path. const trackClick = useCallback( @@ -206,6 +239,9 @@ export function ClientAdvertisementsProvider({ children }) { loading, getActiveAdvertisement, getActiveAdvertisements, + adLists, + listLoading, + getActiveAdvertisementList, trackClick, handleAdCtaClick, dismissPopupForever, diff --git a/src/contexts/ClientCourseReadingProgressContext.jsx b/src/contexts/ClientCourseReadingProgressContext.jsx index e35bc04..0f2da98 100644 --- a/src/contexts/ClientCourseReadingProgressContext.jsx +++ b/src/contexts/ClientCourseReadingProgressContext.jsx @@ -58,7 +58,12 @@ export function CourseReadingProgressProvider({ children }) { ); return rows; } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not load course progress.'); + toast(err?.response?.data?.message ?? 'Could not load course progress.', { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -101,7 +106,12 @@ export function CourseReadingProgressProvider({ children }) { delete next[lessonUuid]; return next; }); - toast.error(err?.response?.data?.message ?? 'Could not update progress.'); + toast(err?.response?.data?.message ?? 'Could not update progress.', { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } }, []); diff --git a/src/contexts/ClientCoursesContext.jsx b/src/contexts/ClientCoursesContext.jsx index 5c58c0c..d10cf25 100644 --- a/src/contexts/ClientCoursesContext.jsx +++ b/src/contexts/ClientCoursesContext.jsx @@ -42,7 +42,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.get("/client/courses"); setCourses(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load courses."); + toast(err?.response?.data?.message ?? "Could not load courses.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setCoursesLoading(false); } @@ -58,7 +63,12 @@ export function ClientCoursesProvider({ children }) { if (err?.response?.status === 403) { setCourseBlocked(true); // let the UI show an upgrade prompt } else { - toast.error(err?.response?.data?.message ?? "Could not load course."); + toast(err?.response?.data?.message ?? "Could not load course.", { + action: { + label: "Close", + onClick: () => {} + } + }); } } finally { setCourseLoading(false); @@ -71,7 +81,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.get(`/client/courses/${courseId}/units/${unitId}`); setUnit(data.data ?? null); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load unit."); + toast(err?.response?.data?.message ?? "Could not load unit.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setUnitLoading(false); } @@ -85,7 +100,12 @@ export function ClientCoursesProvider({ children }) { ); setLesson(data.data ?? null); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load lesson."); + toast(err?.response?.data?.message ?? "Could not load lesson.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLessonLoading(false); } @@ -99,7 +119,12 @@ export function ClientCoursesProvider({ children }) { ); setQuiz(data.data ?? null); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load quiz."); + toast(err?.response?.data?.message ?? "Could not load quiz.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setQuizLoading(false); } @@ -111,7 +136,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.get(`/client/courses/${courseId}/assessment`); setAssessment(data.data ?? null); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load assessment."); + toast(err?.response?.data?.message ?? "Could not load assessment.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setAssessmentLoading(false); } @@ -125,7 +155,12 @@ export function ClientCoursesProvider({ children }) { ); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not submit quiz."); + toast(err?.response?.data?.message ?? "Could not submit quiz.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } }, []); @@ -135,7 +170,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.post(`/client/courses/${courseId}/assessment/${assessmentId}/start`); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not start assessment."); + toast(err?.response?.data?.message ?? "Could not start assessment.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } }, []); @@ -167,7 +207,12 @@ export function ClientCoursesProvider({ children }) { ); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not submit assessment."); + toast(err?.response?.data?.message ?? "Could not submit assessment.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } }, []); @@ -184,7 +229,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.get("/client/course-purchases"); setPurchases(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load purchases."); + toast(err?.response?.data?.message ?? "Could not load purchases.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setPurchasesLoading(false); } }, []); @@ -194,7 +244,12 @@ export function ClientCoursesProvider({ children }) { const { data } = await api.post("/client/course-purchases/order", { product_id: productId }); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not create order."); + toast(err?.response?.data?.message ?? "Could not create order.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setPurchaseLoading(false); } }, []); @@ -203,10 +258,20 @@ export function ClientCoursesProvider({ children }) { setPurchaseLoading(true); try { const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId }); - toast.success("Purchase confirmed! You now have access to this course."); + toast("Purchase confirmed! You now have access to this course.", { + action: { + label: "Close", + onClick: () => {} + } + }); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not capture payment."); + toast(err?.response?.data?.message ?? "Could not capture payment.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setPurchaseLoading(false); } }, []); diff --git a/src/contexts/ClientGroupContext.jsx b/src/contexts/ClientGroupContext.jsx index 32b6991..b9a5293 100644 --- a/src/contexts/ClientGroupContext.jsx +++ b/src/contexts/ClientGroupContext.jsx @@ -30,7 +30,12 @@ export function GroupProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); diff --git a/src/contexts/ClientTaskContext.jsx b/src/contexts/ClientTaskContext.jsx index bd2209a..5d99145 100644 --- a/src/contexts/ClientTaskContext.jsx +++ b/src/contexts/ClientTaskContext.jsx @@ -45,7 +45,12 @@ export function TaskProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -58,7 +63,12 @@ export function TaskProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setCompletionLoading(false); @@ -155,7 +165,12 @@ export function TaskProvider({ children }) { payload ); const data = res.data?.data ?? null; - toast.success('Task submitted successfully.'); + toast('Task submitted successfully.', { + action: { + label: "Close", + onClick: () => {} + } + }); // Immediately update latest completion so UI reflects the new state setLatestCompletion(data); // Prepend to history if it's already loaded diff --git a/src/contexts/ClientTaskProgressContext.jsx b/src/contexts/ClientTaskProgressContext.jsx index 2bf767c..a75403c 100644 --- a/src/contexts/ClientTaskProgressContext.jsx +++ b/src/contexts/ClientTaskProgressContext.jsx @@ -45,7 +45,12 @@ export function TaskProgressProvider({ children }) { return await fn(); } catch (err) { const message = err?.response?.data?.message ?? 'Something went wrong.'; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); diff --git a/src/contexts/ClientTiersProvider.jsx b/src/contexts/ClientTiersProvider.jsx index d4ab674..dac2593 100644 --- a/src/contexts/ClientTiersProvider.jsx +++ b/src/contexts/ClientTiersProvider.jsx @@ -44,10 +44,20 @@ export function ClientTiersProvider({ children }) { // known-active tier — this prevents the "Free" flash after an upgrade. setMyTier(prev => (tier === null && prev?.status === 'active') ? prev : tier); if (tier?.just_expired) { - toast.warning("Your subscription has expired. You've been moved to the Free plan."); + toast("Your subscription has expired. You've been moved to the Free plan.", { + action: { + label: "Close", + onClick: () => {} + } + }); } } catch (err) { - if (!silent) toast.error(err?.response?.data?.message ?? "Could not load tier."); + if (!silent) toast(err?.response?.data?.message ?? "Could not load tier.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { if (!silent) setTierLoading(false); } @@ -78,7 +88,12 @@ export function ClientTiersProvider({ children }) { const { data } = await api.get("/client/tiers/me/history"); setTierHistory(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load tier history."); + toast(err?.response?.data?.message ?? "Could not load tier history.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setTierHistoryLoading(false); } @@ -90,7 +105,12 @@ export function ClientTiersProvider({ children }) { const { data } = await api.get("/client/tiers/plans"); setPlans(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load plans."); + toast(err?.response?.data?.message ?? "Could not load plans.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setPlansLoading(false); } @@ -118,7 +138,12 @@ export function ClientTiersProvider({ children }) { const { data } = await api.post("/client/tiers/checkout/order", payload); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not create order."); + toast(err?.response?.data?.message ?? "Could not create order.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setCheckoutLoading(false); @@ -130,14 +155,24 @@ export function ClientTiersProvider({ children }) { setCheckoutLoading(true); try { const { data } = await api.post("/client/tiers/checkout/capture", { order_id }); - toast.success(data.message ?? "Payment successful. Tier activated."); + toast(data.message ?? "Payment successful. Tier activated.", { + action: { + label: "Close", + onClick: () => {} + } + }); setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" }); // Refresh from server so the browser cache holds fresh Premium data — prevents // subsequent getMyTier() calls from getting a stale 304 with the old Free/null response. getMyTier({ silent: true }); return data.data ?? null; } catch (err) { - toast.error(err?.response?.data?.message ?? "Payment capture failed."); + toast(err?.response?.data?.message ?? "Payment capture failed.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setCheckoutLoading(false); @@ -162,7 +197,12 @@ export function ClientTiersProvider({ children }) { const { data } = await api.get("/client/tiers/me/payments"); setPayments(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load payment history."); + toast(err?.response?.data?.message ?? "Could not load payment history.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setPaymentsLoading(false); } diff --git a/src/contexts/ProfileProvider.jsx b/src/contexts/ProfileProvider.jsx index 6f9967e..0e47bc2 100644 --- a/src/contexts/ProfileProvider.jsx +++ b/src/contexts/ProfileProvider.jsx @@ -34,7 +34,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) { setProfile(fresh); return fresh; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load profile."); + toast(err?.response?.data?.message ?? "Could not load profile.", { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setProfileLoading(false); @@ -47,10 +52,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) { const { data } = await api.put(`${apiBase}/profile`, { personal_info }); setProfile(data.data ?? null); setUser((prev) => ({ ...prev, ...data.data })); - toast.success(data.message ?? "Profile updated."); + toast(data.message ?? "Profile updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: true, data: data.data }; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not update profile."); + toast(err?.response?.data?.message ?? "Could not update profile.", { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: false }; } finally { setProfileLoading(false); @@ -63,7 +78,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) { const { data } = await api.get(`${apiBase}/sessions`); setSessions(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load sessions."); + toast(err?.response?.data?.message ?? "Could not load sessions.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSessionsLoading(false); } @@ -74,10 +94,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) { try { await api.delete(`${apiBase}/sessions/${sessionId}`); setSessions((prev) => prev.filter((s) => s.session_id !== sessionId)); - toast.success("Session revoked."); + toast("Session revoked.", { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: true }; } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not revoke session."); + toast(err?.response?.data?.message ?? "Could not revoke session.", { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: false }; } finally { setRevokingId(null); @@ -92,10 +122,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) { const { data } = await api.post(`${apiBase}/profile/avatar`, formData); setProfile(data.data ?? null); setUser((prev) => ({ ...prev, personal_info: data.data?.personal_info })); - toast.success('Avatar updated.'); + toast('Avatar updated.', { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: true }; } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not update avatar.'); + toast(err?.response?.data?.message ?? 'Could not update avatar.', { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: false }; } finally { setAvatarLoading(false); @@ -114,10 +154,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) { ...prev, personal_info: { ...(prev?.personal_info ?? {}), avatar: null }, })); - toast.success('Avatar removed.'); + toast('Avatar removed.', { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: true }; } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not remove avatar.'); + toast(err?.response?.data?.message ?? 'Could not remove avatar.', { + action: { + label: "Close", + onClick: () => {} + } + }); return { success: false }; } finally { setAvatarLoading(false); @@ -130,7 +180,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) { const { data } = await api.get(`${apiBase}/achievements`); setAchievements(data.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not load achievements."); + toast(err?.response?.data?.message ?? "Could not load achievements.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setAchievementsLoading(false); } diff --git a/src/contexts/StaffGroupContext.jsx b/src/contexts/StaffGroupContext.jsx index 8e414b8..ca4b487 100644 --- a/src/contexts/StaffGroupContext.jsx +++ b/src/contexts/StaffGroupContext.jsx @@ -41,7 +41,12 @@ export const StaffGroupProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -56,7 +61,12 @@ export const StaffGroupProvider = ({ children }) => { return await fn(); } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setMembersLoading(false); diff --git a/src/contexts/StaffScoreContext.jsx b/src/contexts/StaffScoreContext.jsx index 3d88f0b..77b99dc 100644 --- a/src/contexts/StaffScoreContext.jsx +++ b/src/contexts/StaffScoreContext.jsx @@ -27,7 +27,12 @@ export const StaffScoreProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); diff --git a/src/contexts/StaffTaskContext.jsx b/src/contexts/StaffTaskContext.jsx index 7f7653f..d389ce9 100644 --- a/src/contexts/StaffTaskContext.jsx +++ b/src/contexts/StaffTaskContext.jsx @@ -53,7 +53,12 @@ export const StaffTaskProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); @@ -134,7 +139,12 @@ export const StaffTaskProvider = ({ children }) => { const res = await api.post(`${BASE}/task-lists`, payload); const created = res.data?.data; if (created) setTaskLists((prev) => [created, ...prev]); - toast.success("Task list created."); + toast("Task list created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -153,7 +163,12 @@ export const StaffTaskProvider = ({ children }) => { if (taskList?.task_list_id === taskListId) setTaskList((prev) => ({ ...prev, ...updated })); } - toast.success("Task list updated."); + toast("Task list updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request, taskList] @@ -166,7 +181,12 @@ export const StaffTaskProvider = ({ children }) => { const res = await api.post(`${BASE}/task-lists/${taskListId}/archive`); setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId)); if (taskList?.task_list_id === taskListId) setTaskList(null); - toast.success("Task list archived."); + toast("Task list archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request, taskList] @@ -178,7 +198,12 @@ export const StaffTaskProvider = ({ children }) => { request(async () => { const res = await api.post(`${BASE}/task-lists/${taskListId}/restore`); setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId)); - toast.success("Task list restored."); + toast("Task list restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -193,7 +218,12 @@ export const StaffTaskProvider = ({ children }) => { setTaskLists((prev) => prev.filter((tl) => !archived_ids.includes(tl.task_list_id)) ); - toast.success(`${archived_ids.length} task list(s) archived.`); + toast(`${archived_ids.length} task list(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -208,7 +238,12 @@ export const StaffTaskProvider = ({ children }) => { setTaskLists((prev) => prev.filter((tl) => !restored_ids.includes(tl.task_list_id)) ); - toast.success(`${restored_ids.length} task list(s) restored.`); + toast(`${restored_ids.length} task list(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -290,7 +325,12 @@ export const StaffTaskProvider = ({ children }) => { ) ); } - toast.success("Task created."); + toast("Task created.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -320,7 +360,12 @@ export const StaffTaskProvider = ({ children }) => { ); if (task?.task_id === taskId) setTask((prev) => ({ ...prev, ...updated })); } - toast.success("Task updated."); + toast("Task updated.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request, task] @@ -339,7 +384,12 @@ export const StaffTaskProvider = ({ children }) => { : tl ) ); - toast.success("Task archived."); + toast("Task archived.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -351,7 +401,12 @@ export const StaffTaskProvider = ({ children }) => { request(async () => { const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/${taskId}/restore`); setTasks((prev) => prev.filter((t) => t.task_id !== taskId)); - toast.success("Task restored."); + toast("Task restored.", { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -364,7 +419,12 @@ export const StaffTaskProvider = ({ children }) => { const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-archive`, { ids }); const { archived_ids = [] } = res.data?.data ?? {}; setTasks((prev) => prev.filter((t) => !archived_ids.includes(t.task_id))); - toast.success(`${archived_ids.length} task(s) archived.`); + toast(`${archived_ids.length} task(s) archived.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] @@ -377,7 +437,12 @@ export const StaffTaskProvider = ({ children }) => { const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-restore`, { ids }); const { restored_ids = [] } = res.data?.data ?? {}; setTasks((prev) => prev.filter((t) => !restored_ids.includes(t.task_id))); - toast.success(`${restored_ids.length} task(s) restored.`); + toast(`${restored_ids.length} task(s) restored.`, { + action: { + label: "Close", + onClick: () => {} + } + }); return res.data; }), [request] diff --git a/src/contexts/StaffUserContext.jsx b/src/contexts/StaffUserContext.jsx index 7621841..1229898 100644 --- a/src/contexts/StaffUserContext.jsx +++ b/src/contexts/StaffUserContext.jsx @@ -37,7 +37,12 @@ export const StaffUserProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); - toast.error(message); + toast(message, { + action: { + label: "Close", + onClick: () => {} + } + }); return null; } finally { setLoading(false); diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx index 7d123c0..d7d87af 100644 --- a/src/modules/admin/layouts/AdminLayout.jsx +++ b/src/modules/admin/layouts/AdminLayout.jsx @@ -17,7 +17,7 @@ import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add import { TooltipProvider } from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" -import { Toaster } from "sonner" +import { Toaster } from "@/components/ui/sonner" import { cn } from "@/lib/utils" import UserMenu from "@/components/generic/UserMenu" diff --git a/src/modules/admin/pages/courses/CourseAssessment.jsx b/src/modules/admin/pages/courses/CourseAssessment.jsx index ac824e4..8f2aeac 100644 --- a/src/modules/admin/pages/courses/CourseAssessment.jsx +++ b/src/modules/admin/pages/courses/CourseAssessment.jsx @@ -248,7 +248,12 @@ export default function CourseAssessment() { setLocalAssessment(result); } catch (err) { if (err?.response?.status !== 404) { - toast.error(err?.response?.data?.message ?? "Could not load assessment."); + toast(err?.response?.data?.message ?? "Could not load assessment.", { + action: { + label: "Close", + onClick: () => {} + } + }); } } finally { setInitializing(false); diff --git a/src/modules/admin/pages/courses/ViewAssessment.jsx b/src/modules/admin/pages/courses/ViewAssessment.jsx index d59f657..6057206 100644 --- a/src/modules/admin/pages/courses/ViewAssessment.jsx +++ b/src/modules/admin/pages/courses/ViewAssessment.jsx @@ -355,7 +355,12 @@ export default function ViewAssessment() { setLocalAssessment(data?.data?.data ?? null); } catch (err) { if (err?.response?.status !== 404) { - toast.error(err?.response?.data?.message ?? "Could not load assessment."); + toast(err?.response?.data?.message ?? "Could not load assessment.", { + action: { + label: "Close", + onClick: () => {} + } + }); } } })(); diff --git a/src/modules/admin/pages/courses/units/ModifyQuiz.jsx b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx index 0acc460..296c45a 100644 --- a/src/modules/admin/pages/courses/units/ModifyQuiz.jsx +++ b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx @@ -240,7 +240,12 @@ export default function ModifyQuiz() { setLocalQuiz(result); } catch (err) { if (err?.response?.status !== 404) { - toast.error(err?.response?.data?.message ?? "Could not load quiz."); + toast(err?.response?.data?.message ?? "Could not load quiz.", { + action: { + label: "Close", + onClick: () => {} + } + }); } // 404 → no quiz yet, stay in create mode with localQuiz = null } finally { diff --git a/src/modules/admin/pages/notifications/NotificationSettings.jsx b/src/modules/admin/pages/notifications/NotificationSettings.jsx index 9814fc7..eb58b2b 100644 --- a/src/modules/admin/pages/notifications/NotificationSettings.jsx +++ b/src/modules/admin/pages/notifications/NotificationSettings.jsx @@ -41,7 +41,12 @@ export default function NotificationSettings() { const { data } = await api.get("/admin/notification-settings"); setSettings(data?.data ?? []); } catch (err) { - toast.error(err?.response?.data?.message ?? "Failed to load notification settings."); + toast(err?.response?.data?.message ?? "Failed to load notification settings.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } @@ -58,9 +63,22 @@ export default function NotificationSettings() { }); const updated = data?.data?.data; setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s))); - toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`); + toast( + `${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`, + { + action: { + label: "Close", + onClick: () => {} + } + } + ); } catch (err) { - toast.error(err?.response?.data?.message ?? "Failed to update setting."); + toast(err?.response?.data?.message ?? "Failed to update setting.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSavingJob(null); } @@ -75,9 +93,19 @@ export default function NotificationSettings() { }); const updated = data?.data?.data; setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s))); - toast.success("Schedule updated — took effect immediately, no restart needed."); + toast("Schedule updated — took effect immediately, no restart needed.", { + action: { + label: "Close", + onClick: () => {} + } + }); } catch (err) { - toast.error(err?.response?.data?.message ?? "Failed to update schedule."); + toast(err?.response?.data?.message ?? "Failed to update schedule.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSavingJob(null); } diff --git a/src/modules/admin/pages/tiers/PaymentPolicy.jsx b/src/modules/admin/pages/tiers/PaymentPolicy.jsx index 7f8261a..fa1ad0b 100644 --- a/src/modules/admin/pages/tiers/PaymentPolicy.jsx +++ b/src/modules/admin/pages/tiers/PaymentPolicy.jsx @@ -100,10 +100,20 @@ export default function PaymentPolicy() { }, promo_rules: promoRules, }); - toast.success("Payment policy saved."); + toast("Payment policy saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); navigate("/admin/tiers/plans"); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not save payment policy."); + toast(err?.response?.data?.message ?? "Could not save payment policy.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSaving(false); } @@ -113,10 +123,25 @@ export default function PaymentPolicy() { const handleAddPromo = () => { const code = addForm.code.trim().toUpperCase(); - if (!code) { toast.error("Code is required."); return; } - if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; } + if (!code) { toast("Code is required.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } + if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } if (promoRules.some((r) => r.code.toUpperCase() === code)) { - toast.error("A rule with this code already exists."); return; + toast("A rule with this code already exists.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } const rule = { diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx index c3d14f2..a2c16fb 100644 --- a/src/modules/admin/pages/tiers/ViewPlan.jsx +++ b/src/modules/admin/pages/tiers/ViewPlan.jsx @@ -220,9 +220,19 @@ function PaymentPolicyTab({ planId, plan }) { }, promo_rules: promoRules, }); - toast.success("Payment policy saved."); + toast("Payment policy saved.", { + action: { + label: "Close", + onClick: () => {} + } + }); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not save payment policy."); + toast(err?.response?.data?.message ?? "Could not save payment policy.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSaving(false); } @@ -230,9 +240,24 @@ function PaymentPolicyTab({ planId, plan }) { const handleAddPromo = () => { const code = addForm.code.trim().toUpperCase(); - if (!code) { toast.error("Code is required."); return; } - if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; } - if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; } + if (!code) { toast("Code is required.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } + if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } + if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast("A rule with this code already exists.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } const rule = { code, diff --git a/src/modules/admin/pages/users/EditUser.jsx b/src/modules/admin/pages/users/EditUser.jsx index 8b41d53..02e5283 100644 --- a/src/modules/admin/pages/users/EditUser.jsx +++ b/src/modules/admin/pages/users/EditUser.jsx @@ -151,10 +151,20 @@ export default function EditUser() { const res = await updateUser(id, payload); if (res) { - toast.success("User updated successfully."); + toast("User updated successfully.", { + action: { + label: "Close", + onClick: () => {} + } + }); navigate(`../view/${id}`); } else { - toast.error("Failed to update user."); + toast("Failed to update user.", { + action: { + label: "Close", + onClick: () => {} + } + }); } }; diff --git a/src/modules/auth/components/ForgotPasswordForm.jsx b/src/modules/auth/components/ForgotPasswordForm.jsx new file mode 100644 index 0000000..f0d8563 --- /dev/null +++ b/src/modules/auth/components/ForgotPasswordForm.jsx @@ -0,0 +1,349 @@ +/*********************************************************************************************************************************************************************** + * File Name: ForgotPasswordForm.jsx + * Type of Program: Frontend Component + * Description: Password reset flow — same procedure for every acc_type (admin, + * staff, user); only reg_type matters (Google accounts are turned + * away, see LoginForm's "Please log in with Google" precedent). + * Step 1 — Email (checks reg_type server-side, sends OTP if system) + * Step 2 — OTP + new password + confirm, single submit + * Module: User Credentials + * Author: lash0000 + * Date Created: Jul. 4, 2026 + ***********************************************************************************************************************************************************************/ +import { useState, useEffect } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { useForm, Controller } from 'react-hook-form' +import { useAuth } from '@/contexts/AuthContext' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { cn } from '@/lib/utils' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Eye, EyeOff, LoaderCircle } from 'lucide-react' + +const emailSchema = z.object({ + email: z.string().email('Invalid email address'), +}) + +const resetSchema = z + .object({ + otp: z.string().length(6, 'Enter all 6 digits').regex(/^\d{6}$/, 'OTP must contain only digits'), + new_password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Must contain at least one uppercase letter') + .regex(/[0-9]/, 'Must contain at least one number'), + confirm_password: z.string().min(1, 'Please confirm your password'), + }) + .refine((d) => d.new_password === d.confirm_password, { + message: 'Passwords do not match', + path: ['confirm_password'], + }) + +export function ForgotPasswordForm({ className, ...props }) { + const navigate = useNavigate() + const { forgotPassword, resetPassword } = useAuth() + + // 0 = email, 1 = otp + new password + const [step, setStep] = useState(0) + const [pendingEmail, setPendingEmail] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [resendCooldown, setResendCooldown] = useState(0) + const [errorDialogOpen, setErrorDialogOpen] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [successDialogOpen, setSuccessDialogOpen] = useState(false) + + useEffect(() => { + if (resendCooldown <= 0) return + const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000) + return () => clearTimeout(t) + }, [resendCooldown]) + + // ── Step 1: Email ────────────────────────────────────────────────────────── + const { + register: regEmail, + handleSubmit: submitEmail, + formState: { errors: errEmail, isSubmitting: isRequesting }, + } = useForm({ + resolver: zodResolver(emailSchema), + defaultValues: { email: '' }, + }) + + const onEmailSubmit = async ({ email }) => { + const result = await forgotPassword({ email }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setPendingEmail(email) + setResendCooldown(30) + setStep(1) + } + + // ── Step 2: OTP + new password ──────────────────────────────────────────── + const { + control: resetControl, + register: regReset, + handleSubmit: submitReset, + formState: { errors: errReset, isSubmitting: isResetting }, + } = useForm({ + resolver: zodResolver(resetSchema), + defaultValues: { otp: '', new_password: '', confirm_password: '' }, + }) + + const onResetSubmit = async ({ otp, new_password }) => { + const result = await resetPassword({ email: pendingEmail, otp, new_password }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setSuccessDialogOpen(true) + } + + const handleResend = async () => { + if (resendCooldown > 0) return + + const result = await forgotPassword({ email: pendingEmail }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setResendCooldown(30) + } + + return ( + <> +
+ {/* ── Step 1: Email ── */} + {step === 0 && ( +
+
+

Forgot your password?

+

+ Enter your email and we'll send you a code to reset it. +

+
+ +
+ + e.target.removeAttribute('readonly')} + {...regEmail('email')} + /> + {errEmail.email && ( +

{errEmail.email.message}

+ )} +
+ + + +

+ Remembered it?{' '} + + Back to login + +

+
+ )} + + {/* ── Step 2: OTP + new password ── */} + {step === 1 && ( +
+
+

Reset your password.

+

+ We sent a 6-digit code to{' '} + {pendingEmail}. + Enter it below along with your new password. +

+
+ +
+ + ( + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + )} + /> + {errReset.otp && ( +

{errReset.otp.message}

+ )} +
+ +
+ + {resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"} + + +
+ + {/* New password */} +
+ +
+ + +
+ {errReset.new_password && ( +

{errReset.new_password.message}

+ )} +
+ + {/* Confirm password */} +
+ +
+ + +
+ {errReset.confirm_password && ( +

{errReset.confirm_password.message}

+ )} +
+ + + + + + + + )} +
+ + {/* Error Dialog */} + + + + Something went wrong + {errorMessage} + + + setErrorDialogOpen(false)}>Okay + + + + + {/* Success Dialog */} + + + + Password changed + + Your password has been reset successfully. Please log in with your new password. + + + + navigate('/login')}>Go to login + + + + + ) +} diff --git a/src/modules/auth/components/LoginForm.jsx b/src/modules/auth/components/LoginForm.jsx index d4823db..290a48d 100644 --- a/src/modules/auth/components/LoginForm.jsx +++ b/src/modules/auth/components/LoginForm.jsx @@ -19,6 +19,8 @@ import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { cn } from '@/lib/utils' +import { getRoleHomePath } from '@/utils/roleRedirect.util' +import { OtpVerifyForm } from './OtpVerifyForm' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -48,6 +50,7 @@ export function LoginForm({ className, ...props }) { const [passwordVisible, setPasswordVisible] = useState(false) const [errorDialogOpen, setErrorDialogOpen] = useState(false) const [errorMessage, setErrorMessage] = useState('') + const [otpEmail, setOtpEmail] = useState(null) const { register, @@ -58,16 +61,21 @@ export function LoginForm({ className, ...props }) { defaultValues: { email: '', password: '' }, }) + const handleAuthSuccess = (user) => { + navigate(getRoleHomePath(user)) + } + const onSubmit = async ({ email, password }) => { const result = await login({ email, password }) if (result.success) { - switch (result.user.acc_type) { - case 'admin': navigate('/admin'); break - case 'staff': navigate('/staff'); break - case 'client': navigate('/client'); break - default: navigate('/login') + if (result.otpRequired === false) { + // Trusted device — session was issued directly, no OTP step needed. + handleAuthSuccess(result.user) + return } + // Credentials confirmed — an OTP was emailed. Tokens aren't issued yet. + setOtpEmail(result.email) return } @@ -90,6 +98,18 @@ export function LoginForm({ className, ...props }) { window.location.href = '/api/auth/google' } + if (otpEmail) { + return ( + setOtpEmail(null)} + /> + ) + } + return ( <>
{ + if (resendCooldown <= 0) return + const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000) + return () => clearTimeout(t) + }, [resendCooldown]) + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting: isVerifying }, + } = useForm({ + resolver: zodResolver(otpSchema), + defaultValues: { otp: '' }, + }) + + const onVerify = async ({ otp }) => { + const result = await verifyOTP({ email, otp }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + onSuccess(result.user) + } + + const handleResend = async () => { + if (resendCooldown > 0) return + + const result = await resendOTP({ email }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setResendCooldown(30) + reset() + } + + return ( + <> + +
+

{title}

+

+ {description ?? ( + <> + We sent a 6-digit code to{' '} + {email}. + It expires in 10 minutes. + + )} +

+
+ +
+ ( + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + )} + /> + {errors.otp && ( +

{errors.otp.message}

+ )} +
+ + + +
+ + {resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"} + + +
+ + {onBack && ( + <> + + + + )} + + + + + + Verification failed + {errorMessage} + + + setErrorDialogOpen(false)}>Okay + + + + + ) +} diff --git a/src/modules/auth/components/RegisterForm.jsx b/src/modules/auth/components/RegisterForm.jsx index b6af084..181a1b6 100644 --- a/src/modules/auth/components/RegisterForm.jsx +++ b/src/modules/auth/components/RegisterForm.jsx @@ -15,18 +15,18 @@ * May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project * May 23, 2026 lash0000 002 birthday + occupation required; all calls via useAuth (register, verifyOTP, resendOTP) ***********************************************************************************************************************************************************************/ -import { useState, useRef, useEffect } from 'react' +import { useState } from 'react' import { useNavigate, Link, useSearchParams } from 'react-router-dom' -import { useForm, Controller } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { useAuth } from '@/contexts/AuthContext' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { cn } from '@/lib/utils' +import { OtpVerifyForm } from './OtpVerifyForm' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' import { AlertDialog, AlertDialogAction, @@ -72,13 +72,6 @@ const credentialsSchema = z path: ['confirm_password'], }) -const otpSchema = z.object({ - otp: z - .string() - .length(6, 'Enter all 6 digits') - .regex(/^\d{6}$/, 'OTP must contain only digits'), -}) - // ─── Stepper indicator ──────────────────────────────────────────────────────── const STEPS = [ { label: 'Personal info' }, @@ -134,58 +127,10 @@ function StepIndicator({ current }) { ) } -// ─── OTP Cell Input ─────────────────────────────────────────────────────────── -function OtpInput({ value = '', onChange }) { - const cellRefs = Array.from({ length: 6 }, () => useRef(null)) - const digits = value.split('') - - const handleChange = (i, e) => { - const char = e.target.value.replace(/\D/g, '').slice(-1) - const next = [...digits] - next[i] = char - onChange(next.join('')) - if (char && i < 5) cellRefs[i + 1].current?.focus() - } - - const handleKeyDown = (i, e) => { - if (e.key === 'Backspace' && !digits[i] && i > 0) { - const next = [...digits] - next[i - 1] = '' - onChange(next.join('')) - cellRefs[i - 1].current?.focus() - } - } - - const handlePaste = (e) => { - e.preventDefault() - const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6) - onChange(pasted) - cellRefs[Math.min(pasted.length, 5)].current?.focus() - } - - return ( -
- {Array.from({ length: 6 }).map((_, i) => ( - handleChange(i, e)} - onKeyDown={(e) => handleKeyDown(i, e)} - className="w-11 h-12 text-center text-lg font-semibold p-0" - /> - ))} -
- ) -} - // ─── RegisterForm ───────────────────────────────────────────────────────────── export function RegisterForm({ className, ...props }) { const navigate = useNavigate() - const { register: authRegister, verifyOTP, resendOTP } = useAuth() + const { register: authRegister } = useAuth() const [searchParams] = useSearchParams() const groupCode = searchParams.get('group_code') || '' @@ -194,20 +139,12 @@ export function RegisterForm({ className, ...props }) { const [pendingEmail, setPendingEmail] = useState('') const [showPassword, setShowPassword] = useState(false) const [showConfirm, setShowConfirm] = useState(false) - const [resendCooldown, setResendCooldown] = useState(0) const [errorDialogOpen, setErrorDialogOpen] = useState(false) const [errorMessage, setErrorMessage] = useState('') // Accumulated data across steps const [personalData, setPersonalData] = useState({}) - // ── Resend countdown ────────────────────────────────────────────────────── - useEffect(() => { - if (resendCooldown <= 0) return - const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000) - return () => clearTimeout(t) - }, [resendCooldown]) - // ── Step 1: Personal info ───────────────────────────────────────────────── const { register: regPersonal, @@ -278,48 +215,9 @@ export function RegisterForm({ className, ...props }) { } setPendingEmail(email) - setResendCooldown(30) setStep(2) } - // ── Step 3: OTP ─────────────────────────────────────────────────────────── - const { - control: otpControl, - handleSubmit: submitOtp, - reset: resetOtp, - formState: { errors: errOtp, isSubmitting: isVerifying }, - } = useForm({ - resolver: zodResolver(otpSchema), - defaultValues: { otp: '' }, - }) - - const onVerifyOTP = async ({ otp }) => { - const result = await verifyOTP({ email: pendingEmail, otp }) - - if (!result.success) { - setErrorMessage(result.message) - setErrorDialogOpen(true) - return - } - - navigate('/dashboard', { state: { justRegistered: true } }) - } - - const handleResend = async () => { - if (resendCooldown > 0) return - - const result = await resendOTP({ email: pendingEmail }) - - if (!result.success) { - setErrorMessage(result.message) - setErrorDialogOpen(true) - return - } - - setResendCooldown(30) - resetOtp() - } - // ───────────────────────────────────────────────────────────────────────── return ( <> @@ -608,76 +506,18 @@ export function RegisterForm({ className, ...props }) { {/* ── Step 3: OTP ── */} {step === 2 && ( -
-
-

Check your email.

-

- We sent a 6-digit code to{' '} - {pendingEmail}. - It expires in 10 minutes. -

-
- -
- ( - - )} - /> - {errOtp.otp && ( -

{errOtp.otp.message}

- )} -
- - - -
- - {resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"} - - -
- - - - - + navigate('/dashboard', { state: { justRegistered: true } })} + onBack={() => setStep(1)} + /> )}
- - {step === 2 ? 'Verification failed' : 'Registration failed'} - + Registration failed {errorMessage} diff --git a/src/modules/auth/pages/ChangePassword.jsx b/src/modules/auth/pages/ChangePassword.jsx index bc4b28b..aeb6b82 100644 --- a/src/modules/auth/pages/ChangePassword.jsx +++ b/src/modules/auth/pages/ChangePassword.jsx @@ -94,7 +94,12 @@ export default function ChangePassword() { // Update local user state so must_change_password is cleared setUser((prev) => ({ ...prev, must_change_password: false })); - toast.success('Password changed successfully. Welcome!'); + toast('Password changed successfully. Welcome!', { + action: { + label: "Close", + onClick: () => {} + } + }); // Redirect to the correct dashboard switch (user?.acc_type) { @@ -104,7 +109,12 @@ export default function ChangePassword() { default: navigate('/'); } } catch (err) { - toast.error(err?.response?.data?.message || 'Could not change password.'); + toast(err?.response?.data?.message || 'Could not change password.', { + action: { + label: "Close", + onClick: () => {} + } + }); } }; diff --git a/src/modules/auth/pages/ForgotPassword.jsx b/src/modules/auth/pages/ForgotPassword.jsx new file mode 100644 index 0000000..96c0ac3 --- /dev/null +++ b/src/modules/auth/pages/ForgotPassword.jsx @@ -0,0 +1,54 @@ +/*********************************************************************************************************************************************************************** + * File Name: ForgotPassword.jsx + * Type of Program: Frontend Page + * Description: Forgot-password page. Route: /forgot-password + * Module: User Credentials + * Author: lash0000 + * Date Created: Jul. 4, 2026 + ***********************************************************************************************************************************************************************/ +import { Link } from 'react-router-dom' +import { ForgotPasswordForm } from '../components/ForgotPasswordForm' +import { MetadataProvider } from '@/contexts/MetadataContext' + +export default function ForgotPassword() { + return ( + +
+
+
+ +
+ Philproperties +
+
+ Philproperties +
+ +
+ +
+
+ +
+
+
+ +
+ Philproperties +
+
+
+ ) +} diff --git a/src/modules/auth/pages/Intro.jsx b/src/modules/auth/pages/Intro.jsx index 3e001de..206d308 100644 --- a/src/modules/auth/pages/Intro.jsx +++ b/src/modules/auth/pages/Intro.jsx @@ -17,7 +17,7 @@ import { Label } from '@/components/ui/label' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Loader2 } from 'lucide-react' import { toast } from 'sonner' -import { Toaster } from 'sonner' +import { Toaster } from '@/components/ui/sonner' import api from '@/utils/api.util' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -120,7 +120,15 @@ export default function IntroPage() { setUser(prev => ({ ...prev, ...data.data })) navigate('/dashboard') } catch (err) { - toast.error(err?.response?.data?.message ?? 'Could not save your info. Please try again.') + toast( + err?.response?.data?.message ?? 'Could not save your info. Please try again.', + { + action: { + label: "Close", + onClick: () => {} + } + } + ) } finally { setLoading(false) } diff --git a/src/modules/auth/pages/OAuthCallback.jsx b/src/modules/auth/pages/OAuthCallback.jsx index a631828..36e8317 100644 --- a/src/modules/auth/pages/OAuthCallback.jsx +++ b/src/modules/auth/pages/OAuthCallback.jsx @@ -3,24 +3,40 @@ * Type of Program: Frontend Page * Description: Landing page after the backend completes Google OIDC. * - * Happy path → backend set the refreshToken cookie and redirected here. - * App.jsx's restoreSession() fires on mount, picks up the cookie, - * and calls /auth/refresh → sets user. PublicRoute then redirects - * to the appropriate dashboard. This page shows a loading spinner - * for the brief moment before that redirect fires. + * OTP path → backend confirmed the Google identity but, like every other + * login path, still gates on an OTP before issuing tokens. It + * redirects here with ?otpRequired=true&email= and has + * NOT set a refresh cookie yet. This page renders the shared + * OtpVerifyForm; once verified, AuthContext has user/tokens + * set and we navigate to the account's home route ourselves. + * + * Trusted path → this device already cleared an OTP recently and its trust + * window is still valid. Backend redirects with + * ?otpRequired=false, having already set the refresh cookie — + * this page calls restoreSession() to pull the access token + * from it, then navigates to the account's home route. * * Error path → backend could not complete OIDC (state mismatch, token exchange * failure, deactivated account, etc.). It redirected here with * ?error=. No refresh cookie was set, so restoreSession() * will fail and the user stays on this page to see the error. * + * Fallback → neither param present (shouldn't normally happen now that the + * backend always redirects with one or the other) — falls back + * to the old spinner + restoreSession()/PublicRoute behavior. + * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 + * Date Modified: Jul. 5, 2026 — trusted-device OTP skip path ***********************************************************************************************************************************************************************/ -import { useSearchParams, Link } from 'react-router-dom' +import { useEffect } from 'react' +import { useSearchParams, Link, useNavigate } from 'react-router-dom' import { LoaderCircle, ShieldBan, UserX, AlertTriangle, RefreshCw, Clock } from 'lucide-react' import { Button } from '@/components/ui/button' +import { useAuth } from '@/contexts/AuthContext' import { useDateFormat } from '@/hooks/useDateFormat' +import { getRoleHomePath } from '@/utils/roleRedirect.util' +import { OtpVerifyForm } from '@/modules/auth/components/OtpVerifyForm' const ERROR_MAP = { access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' }, @@ -32,8 +48,47 @@ const ERROR_MAP = { export default function OAuthCallback() { const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { restoreSession } = useAuth() const { fmtDateTime } = useDateFormat() const error = searchParams.get('error') + const otpRequiredParam = searchParams.get('otpRequired') + const otpRequired = otpRequiredParam === 'true' + const trusted = otpRequiredParam === 'false' + const email = searchParams.get('email') + + useEffect(() => { + if (!trusted) return + restoreSession().then(({ success, user }) => { + navigate(success ? getRoleHomePath(user) : '/login', { replace: true }) + }) + }, [trusted]) + + if (trusted) { + return ( +
+
+ +

Signing you in...

+
+
+ ) + } + + if (otpRequired && email) { + return ( +
+
+ navigate(getRoleHomePath(user), { replace: true })} + title="Verify your sign-in" + description={`We sent a 6-digit code to ${email} to finish signing in with Google. It expires in 10 minutes.`} + /> +
+
+ ) + } if (error === 'account_banned') { const reason = searchParams.get('reason') diff --git a/src/modules/auth/routes/AuthRoutes.jsx b/src/modules/auth/routes/AuthRoutes.jsx index 4c680c0..badd8df 100644 --- a/src/modules/auth/routes/AuthRoutes.jsx +++ b/src/modules/auth/routes/AuthRoutes.jsx @@ -5,6 +5,7 @@ import LandingLayout from '@/modules/public/layouts/LandingLayout' import LandingPage from '@/modules/public/pages/LandingPage' import Login from '../pages/Login' import Register from '../pages/Register' +import ForgotPassword from '../pages/ForgotPassword' import OAuthCallback from '../pages/OAuthCallback' import Suspended from '@/modules/public/pages/Suspended' @@ -19,6 +20,7 @@ export const AuthRoutes = { { index: true, element: }, { path: "login", element: }, { path: "signup", element: }, + { path: "forgot-password", element: }, { path: "auth/callback/google", element: }, { path: "suspended", element: }, ] diff --git a/src/modules/client/components/blocks/FileUpload.jsx b/src/modules/client/components/blocks/FileUpload.jsx index 55e0185..92721d2 100644 --- a/src/modules/client/components/blocks/FileUpload.jsx +++ b/src/modules/client/components/blocks/FileUpload.jsx @@ -178,9 +178,15 @@ const FileUpload = ({ return ok; }); if (rejected.length > 0) { - setTimeout(() => toast.error( + setTimeout(() => toast( `${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` + - `Accepted types: ${allowed.join(", ")}.` + `Accepted types: ${allowed.join(", ")}.`, + { + action: { + label: "Close", + onClick: () => {} + } + } ), 0); } } @@ -190,14 +196,26 @@ const FileUpload = ({ if (maxFileCount) { const availableSlots = maxFileCount - prev.length; if (availableSlots <= 0) { - setTimeout(() => toast.error( - `You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.` + setTimeout(() => toast( + `You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`, + { + action: { + label: "Close", + onClick: () => {} + } + } ), 0); incoming = []; } else if (incoming.length > availableSlots) { - setTimeout(() => toast.error( + setTimeout(() => toast( `Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` + - `(max ${maxFileCount}).` + `(max ${maxFileCount}).`, + { + action: { + label: "Close", + onClick: () => {} + } + } ), 0); incoming = incoming.slice(0, availableSlots); } @@ -222,11 +240,14 @@ const FileUpload = ({ } }); if (duplicates.length > 0) { - setTimeout(() => toast.error( - duplicates.length === 1 - ? `"${duplicates[0]}" is already attached.` - : `${duplicates.length} files are already attached.` - ), 0); + setTimeout(() => toast(duplicates.length === 1 + ? `"${duplicates[0]}" is already attached.` + : `${duplicates.length} files are already attached.`, { + action: { + label: "Close", + onClick: () => {} + } + }), 0); } const next = prev.concat(toAdd); toAdd.forEach((e) => simulateUpload(e.id)); diff --git a/src/modules/client/components/blocks/ReadCourse.jsx b/src/modules/client/components/blocks/ReadCourse.jsx index 3da47b2..82e18b8 100644 --- a/src/modules/client/components/blocks/ReadCourse.jsx +++ b/src/modules/client/components/blocks/ReadCourse.jsx @@ -40,7 +40,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId, courses.forEach((course) => { const prev = prevCompletedRef.current[course.id]; if (course.completed && prev === false) { - toast.success(`"${course.title}" has been automatically turned in!`); + toast(`"${course.title}" has been automatically turned in!`, { + action: { + label: "Close", + onClick: () => {} + } + }); } prevCompletedRef.current[course.id] = !!course.completed; }); diff --git a/src/modules/client/components/blocks/VisitLink.jsx b/src/modules/client/components/blocks/VisitLink.jsx index 5fd7cb5..82a8106 100644 --- a/src/modules/client/components/blocks/VisitLink.jsx +++ b/src/modules/client/components/blocks/VisitLink.jsx @@ -1,4 +1,4 @@ -import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react"; +import { ExternalLink, CheckCheck, RefreshCcw, Globe } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -9,7 +9,7 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import ResponsiveModal from "@/components/generic/ResponsiveModal"; import { SendHorizonal } from "lucide-react"; @@ -20,47 +20,41 @@ const normalizeUrl = (url) => { return `https://${url}`; }; -// ── Meta fetcher ────────────────────────────────────────────────────────────── -const fetchLinkMeta = async (url) => { - const normalized = normalizeUrl(url); - try { - const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`); - const json = await res.json(); - if (json.status === "success") { - return { - title: json.data.title ?? null, - description: json.data.description ?? null, - image: json.data.image?.url ?? json.data.logo?.url ?? null, - }; - } - } catch { /* silently fail */ } - return { title: null, description: null, image: null }; -}; - const getDomain = (url) => { try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); } catch { return url; } }; +// ── Fallback banner — favicon over a gradient, no external preview fetch ────── +const LinkImageFallback = ({ domain, favicon, className = "h-40" }) => { + const [faviconFailed, setFaviconFailed] = useState(false); + + return ( +
+ {!faviconFailed && favicon ? ( + {domain} setFaviconFailed(true)} + /> + ) : ( + + )} + {domain} +
+ ); +}; + // ── LinkCard ────────────────────────────────────────────────────────────────── const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => { - const [meta, setMeta] = useState({ title: null, description: null, image: null }); - const [loading, setLoading] = useState(true); - const [modalOpen, setModalOpen] = useState(false); + const [modalOpen, setModalOpen] = useState(false); const [viewModalOpen, setViewModalOpen] = useState(false); - useEffect(() => { - if (!link.url) return; - fetchLinkMeta(link.url) - .then((data) => setMeta(data)) - .finally(() => setLoading(false)); - }, [link.url]); - - const domain = getDomain(link.url); - const displayImage = meta.image ?? null; - const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; - const displayTitle = meta.title ?? link.label ?? domain; - const displayDescription = meta.description ?? link.url; + const domain = getDomain(link.url); + const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; + const displayTitle = link.label ?? domain; + const displayDescription = link.url; const handleTurnIn = async () => { await onTurnIn(link.requirement_id); @@ -75,39 +69,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting return ( <> - {loading ? ( -
- ) : displayImage ? ( - {displayTitle} { e.currentTarget.style.display = 'none'; }} - /> - ) : ( -
- {domain} { e.currentTarget.style.display = 'none'; }} - /> - {domain} -
- )} + - - {loading - ? - : displayTitle - } - - - {loading - ? - : displayDescription - } - + {displayTitle} + {displayDescription} {visited ? ( @@ -137,11 +102,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting } > -
-

{link.url}

-
@@ -156,11 +120,6 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting footer={ <> - @@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting } >
- {displayImage ? ( - {displayTitle} { e.currentTarget.style.display = 'none'; }} - /> - ) : ( -
- {domain} { e.currentTarget.style.display = 'none'; }} /> - {domain} -
- )} +
Already submitted — you can unsubmit if needed.
-

{link.url}

+ +
@@ -201,7 +155,7 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting * called when user confirms "Turn In" */ const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => { - const [submittingId, setSubmittingId] = useState(null); + const [submittingId, setSubmittingId] = useState(null); const [unsubmittingId, setUnsubmittingId] = useState(null); const handleTurnIn = async (requirementId) => { diff --git a/src/modules/client/layout/ClientLayout.jsx b/src/modules/client/layout/ClientLayout.jsx index 6fa1ede..98ec9c9 100644 --- a/src/modules/client/layout/ClientLayout.jsx +++ b/src/modules/client/layout/ClientLayout.jsx @@ -1,5 +1,6 @@ import { Outlet, useMatches, useNavigate } from "react-router-dom" import { ThemeSwitcher } from "../components/ThemeSwitcher" +import { useTheme } from "@/contexts/ThemeContext" import { DropdownMenu, DropdownMenuContent, @@ -22,7 +23,7 @@ import { import * as LucideIcons from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" -import { Toaster } from "sonner" +import { Toaster } from "@/components/ui/sonner" import { useAuth } from "@/contexts/AuthContext" import api from "@/utils/api.util" import { ClientProvider } from "@/contexts/provider/ClientProvider" @@ -141,6 +142,7 @@ function getInitials(name = "") { function ClientNav() { const navigate = useNavigate() const { user, logout } = useAuth() + const { setTheme } = useTheme() // Background fetches only — nav rendering never waits on these const { achievements, getAchievements } = useProfile() @@ -212,6 +214,7 @@ function ClientNav() { const handleLogout = async () => { await logout() + setTheme('light') navigate("/login") } @@ -295,7 +298,7 @@ function ClientNav() { navigate("/settings")}> Account Settings - + {/* Documentation @@ -306,7 +309,7 @@ function ClientNav() { setReferOpen(true)}> Refer - + */} e.preventDefault()}> Theme @@ -343,14 +346,18 @@ const ClientLayout = () => { return ( - - - - {showFooter && ( -
- © Philproperties, 2026 -
- )} +
+ +
+ +
+ + {showFooter && ( +
+ © Philproperties, 2026 +
+ )} +
) } diff --git a/src/modules/client/pages/AccountSettings.jsx b/src/modules/client/pages/AccountSettings.jsx index dff543f..492502d 100644 --- a/src/modules/client/pages/AccountSettings.jsx +++ b/src/modules/client/pages/AccountSettings.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { KeyRound, CreditCard, Mail, Megaphone, Info, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react"; +import { KeyRound, CreditCard, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -63,11 +63,21 @@ function SecuritySection({ user, logout }) { const handleSubmit = async (e) => { e.preventDefault(); if (form.new_password !== form.confirm) { - toast.error("New passwords do not match."); + toast("New passwords do not match.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } if (form.new_password.length < 8) { - toast.error("New password must be at least 8 characters."); + toast("New password must be at least 8 characters.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } setLoading(true); @@ -76,13 +86,23 @@ function SecuritySection({ user, logout }) { current_password: form.current_password, new_password: form.new_password, }); - toast.success("Password changed. Logging you out…"); + toast("Password changed. Logging you out…", { + action: { + label: "Close", + onClick: () => {} + } + }); setTimeout(async () => { await logout(); navigate("/login"); }, 1500); } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not change password."); + toast(err?.response?.data?.message ?? "Could not change password.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setLoading(false); } @@ -229,111 +249,6 @@ function SubscriptionSection() { ); } -// ─── Newsletter ─────────────────────────────────────────────────────────────── - -const NEWSLETTER_OPTIONS = [ - { - key: "newsletter_course_updates", - label: "Course updates", - description: "Emails about new courses, lesson releases, and learning milestones.", - }, - { - key: "newsletter_announcements", - label: "Announcements", - description: "Platform news, promotions, and important updates from Philproperties.", - }, -]; - -function NewsletterSection() { - const { profile, getProfile, updateProfile, profileLoading } = useProfile(); - - useEffect(() => { - getProfile(); - }, []); - - const handleToggle = async (key, value) => { - const result = await updateProfile({ [key]: value }); - if (result?.success) { - toast.success(value ? "Preference saved." : "Preference saved."); - } - }; - - return ( -
- {NEWSLETTER_OPTIONS.map((opt, i) => ( -
-
-
-

{opt.label}

-

{opt.description}

-
- {profileLoading ? ( - - ) : ( - handleToggle(opt.key, v)} - /> - )} -
- {i < NEWSLETTER_OPTIONS.length - 1 && } -
- ))} -
- ); -} - -// ─── Course Notices ─────────────────────────────────────────────────────────── -// One-time informational dialogs shown while studying (e.g. InfoDialog in -// UnitList.jsx) — this list grows as more generic client notices are added. - -const NOTICE_OPTIONS = [ - { - key: "show_course_notices", - label: "Course notices", - description: "Informational pop-ups about course readiness, such as when an assessment hasn't been built yet.", - }, -]; - -function CourseNoticesSection() { - const { profile, getProfile, updateProfile, profileLoading } = useProfile(); - - useEffect(() => { - getProfile(); - }, []); - - const handleToggle = async (key, value) => { - const result = await updateProfile({ [key]: value }); - if (result?.success) { - toast.success("Preference saved."); - } - }; - - return ( -
- {NOTICE_OPTIONS.map((opt, i) => ( -
-
-
-

{opt.label}

-

{opt.description}

-
- {profileLoading ? ( - - ) : ( - handleToggle(opt.key, v)} - /> - )} -
- {i < NOTICE_OPTIONS.length - 1 && } -
- ))} -
- ); -} - // ─── Advertisements ─────────────────────────────────────────────────────────── const AD_OPTIONS = [ @@ -372,7 +287,13 @@ function AdvertisementsSection() { } const result = await updateProfile({ [key]: value }); if (result?.success) { - toast.success("Preference saved.", { description: "Reload the page for this to take effect." }); + toast("Preference saved.", { + description: "Reload the page for this to take effect.", + action: { + label: "Close", + onClick: () => {} + } + }); } }; @@ -380,14 +301,26 @@ function AdvertisementsSection() { setConfirmPopupOff(false); const result = await updateProfile({ show_popup_ads: false, show_other_ads: false }); if (result?.success) { - toast.success("Preference saved.", { description: "Reload the page for this to take effect." }); + toast("Preference saved.", { + description: "Reload the page for this to take effect.", + action: { + label: "Close", + onClick: () => {} + } + }); } }; const handleHideAllToggle = async (hide) => { const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide }); if (result?.success) { - toast.success("Preference saved.", { description: "Reload the page for this to take effect." }); + toast("Preference saved.", { + description: "Reload the page for this to take effect.", + action: { + label: "Close", + onClick: () => {} + } + }); } }; @@ -454,69 +387,81 @@ function AdvertisementsSection() { } // ─── Delete Account ─────────────────────────────────────────────────────────── - -function DeleteAccountSection({ logout }) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - - const handleDelete = async () => { - setLoading(true); - try { - await api.delete("/client/profile"); - toast.success("Account deleted. Goodbye!"); - await logout(); - navigate("/login"); - } catch (err) { - toast.error(err?.response?.data?.message ?? "Could not delete account."); - setLoading(false); - } - }; - - return ( - <> -
-
-

Delete account

-

- Permanently remove your account and all associated data. This action cannot be undone. -

-
- -
- - - - - Delete your account? - - This will permanently delete your account and sign you out of all sessions. - Your data cannot be recovered after deletion. - - - - Cancel - - {loading ? "Deleting…" : "Yes, delete my account"} - - - - - - ); -} +// Disabled while still in development — keep implemented for when we're ready +// to expose self-service account deletion. +// +// function DeleteAccountSection({ logout }) { +// const navigate = useNavigate(); +// const [open, setOpen] = useState(false); +// const [loading, setLoading] = useState(false); +// +// const handleDelete = async () => { +// setLoading(true); +// try { +// await api.delete("/client/profile"); +// toast("Account deleted. Goodbye!", { +// action: { +// label: "Close", +// onClick: () => {} +// } +// }); +// await logout(); +// navigate("/login"); +// } catch (err) { +// toast(err?.response?.data?.message ?? "Could not delete account.", { +// action: { +// label: "Close", +// onClick: () => {} +// } +// }); +// setLoading(false); +// } +// }; +// +// return ( +// <> +//
+//
+//

Delete account

+//

+// Permanently remove your account and all associated data. This action cannot be undone. +//

+//
+// +//
+// +// +// +// +// Delete your account? +// +// This will permanently delete your account and sign you out of all sessions. +// Your data cannot be recovered after deletion. +// +// +// +// Cancel +// +// {loading ? "Deleting…" : "Yes, delete my account"} +// +// +// +// +// +// ); +// } // ─── Page ───────────────────────────────────────────────────────────────────── @@ -539,21 +484,13 @@ export default function AccountSettings() { -
- -
- -
- -
-
-
+ {/*
-
+
*/}
); diff --git a/src/modules/client/pages/Checkout.jsx b/src/modules/client/pages/Checkout.jsx index 91c7888..d6df17f 100644 --- a/src/modules/client/pages/Checkout.jsx +++ b/src/modules/client/pages/Checkout.jsx @@ -122,7 +122,12 @@ const Checkout = () => { if (!wasCancelled) return; const orderId = searchParams.get("token"); if (orderId) cancelOrder(orderId); - toast.info("PayPal checkout was cancelled."); + toast("PayPal checkout was cancelled.", { + action: { + label: "Close", + onClick: () => {} + } + }); navigate(`/plans/checkout?plan_id=${planId}`, { replace: true }); }, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps @@ -153,9 +158,19 @@ const Checkout = () => { const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase()); if (result?.valid) { setPromoResult(result); - toast.success("Promo code applied."); + toast("Promo code applied.", { + action: { + label: "Close", + onClick: () => {} + } + }); } else { - toast.error(result?.reason ?? "Invalid promo code."); + toast(result?.reason ?? "Invalid promo code.", { + action: { + label: "Close", + onClick: () => {} + } + }); } }; @@ -171,7 +186,12 @@ const Checkout = () => { ); if (!order) return; const approvalUrl = order.approval_url; - if (!approvalUrl) { toast.error("Could not get PayPal approval URL."); return; } + if (!approvalUrl) { toast("Could not get PayPal approval URL.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } window.location.href = approvalUrl; }; diff --git a/src/modules/client/pages/CourseCheckout.jsx b/src/modules/client/pages/CourseCheckout.jsx index 6088d93..c7a4e28 100644 --- a/src/modules/client/pages/CourseCheckout.jsx +++ b/src/modules/client/pages/CourseCheckout.jsx @@ -68,7 +68,12 @@ export default function CourseCheckout() { if (!wasCancelled) return; const orderId = searchParams.get("token"); if (orderId) cancelCourseOrder(orderId); - toast.info("Payment was cancelled."); + toast("Payment was cancelled.", { + action: { + label: "Close", + onClick: () => {} + } + }); navigate(`/course/${courseId}/checkout`, { replace: true }); }, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps @@ -76,7 +81,12 @@ export default function CourseCheckout() { if (!course?.product?.id) return; const order = await createCourseOrder(course.product.id); if (!order) return; - if (!order.approval_url) { toast.error("Could not get PayPal approval URL."); return; } + if (!order.approval_url) { toast("Could not get PayPal approval URL.", { + action: { + label: "Close", + onClick: () => {} + } + }); return; } window.location.href = order.approval_url; }; diff --git a/src/modules/client/pages/CourseDetails.jsx b/src/modules/client/pages/CourseDetails.jsx index 0c9d186..55e15e8 100644 --- a/src/modules/client/pages/CourseDetails.jsx +++ b/src/modules/client/pages/CourseDetails.jsx @@ -5,8 +5,9 @@ import api from "@/utils/api.util"; import { House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList, - Hourglass, + Hourglass, Check, } from "lucide-react"; +import { cn } from "@/lib/utils"; import CourseBadge from "@/modules/admin/components/courses/CourseBadge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -44,7 +45,6 @@ function formatDuration(seconds = 0) { // ─── Spine / card helpers ────────────────────────────────────────────────────── const INTRO_HEIGHT = 50; -const CX = 0; const useVisibleNodes = (refs, count) => { const [visible, setVisible] = useState(new Set()); @@ -388,66 +388,69 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo const lastMid = mids.length ? mids[mids.length - 1] : 0; const svgH = lastMid + 40; const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0; + const isIssued = !!certificate; return (
{/* Spine */} -
+
{mids.length > 0 && ( - - - {Array.from({ length: 10 }).map((_, i) => { - const y1 = (mids[0] / 10) * i; - const y2 = (mids[0] / 10) * (i + 1); - const revealed = drawnTo >= y2; - return ( + <> + + + {Array.from({ length: 10 }).map((_, i) => { + const y1 = (mids[0] / 10) * i; + const y2 = (mids[0] / 10) * (i + 1); + const revealed = drawnTo >= y2; + return ( + + ); + })} + {mids[0] != null && drawnTo > mids[0] && ( - ); - })} - {mids[0] != null && drawnTo > mids[0] && ( - - )} + )} + {mids.map((mid, i) => { const visible = visibleNodes.has(i); - // Last node (certificate) gets a gold fill const isCert = i === totalNodes - 1; return ( - - - + + {isCert ? : i + 1} + ); })} - + )}
{/* Cards */} -
+
{nodes.map((node, ni) => { const delay = ni * 0.05; const nodeRef = (el) => (cardRefs.current[ni] = el); @@ -543,7 +546,7 @@ const CourseDetails = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [courseId]); - const bannerAd = advertisements["course_details.banner"] ?? null; + const bannerAd = advertisements["course_details.banner"] ?? null; const sidebarAd = advertisements["course_details.sidebar"] ?? null; // Resolve badge image once course loads — issue a client stream token for @@ -563,7 +566,12 @@ const CourseDetails = () => { }, [course?.badge_asset_id, course?.badge_image_url]); if (courseBlocked) { - toast.error("You don't have access to this course. Upgrade your plan."); + toast("You don't have access to this course. Upgrade your plan.", { + action: { + label: "Close", + onClick: () => { } + } + }); navigate("/course", { replace: true }); return null; } @@ -625,10 +633,20 @@ const CourseDetails = () => {
{/* Hero */} -
-
-
-
+
+
+
+ +
+
{(() => { @@ -661,7 +679,7 @@ const CourseDetails = () => {
) : ( - {allCategories.map((cat) => ( - - ))} -
- )} - {/* Advertisement Banner */} {adLoading["course_list.banner"] ? ( @@ -315,7 +310,7 @@ const CoursesList = () => {

No courses found

) : ( -
+
{paginated.map((course) => ( { const { courses, coursesLoading, getCourses } = useClientCourses(); const { myTier, getMyTier, tierMap } = useClientTiers(); const { groups, fetchGroups, loading: groupLoading } = useGroup(); - const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick, dismissPopupForever } = useClientAdvertisements(); + const { + advertisements, getActiveAdvertisements, + adLists, listLoading, getActiveAdvertisementList, + handleAdCtaClick, dismissPopupForever, + } = useClientAdvertisements(); const [modalOpen, setModalOpen] = useState(false); const [selectedCourse, setSelectedCourse] = useState(null); @@ -216,15 +220,19 @@ const Client = () => { const userTier = myTier?.tier ?? "free"; - const heroAd = advertisements["dashboard.hero"] ?? null; + const heroAds = adLists["dashboard.hero"] ?? []; const popupAd = advertisements["dashboard.popup"] ?? null; // Show welcome toast on first registration useEffect(() => { if (!navState?.justRegistered) return; - toast.success('Welcome to Philproperties!', { + toast('Welcome to Philproperties!', { description: 'You earned the Early Access badge. Check your notifications for details.', duration: 6000, + action: { + label: "Close", + onClick: () => {} + } }); window.history.replaceState({}, ''); }, []); @@ -238,11 +246,12 @@ const Client = () => { fetchGroups(); }, []) - // ── Resolve active hero + popup ads once on mount ──────────────────────── + // ── Resolve active popup ad + hero ad carousel once on mount ───────────── useEffect(() => { - getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => { + getActiveAdvertisements(["dashboard.popup"]).then((result) => { if (result["dashboard.popup"]) setPopupOpen(true); }); + getActiveAdvertisementList("dashboard.hero"); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -268,13 +277,13 @@ const Client = () => { return (
-
+
- {/* ── Hero Advertisement ── */} - {adLoading["dashboard.hero"] ? ( + {/* ── Hero Advertisement Carousel ── */} + {listLoading["dashboard.hero"] ? ( ) : ( - + )} {/* ── My Groups ── */} diff --git a/src/modules/client/pages/MyCertificates.jsx b/src/modules/client/pages/MyCertificates.jsx index bb022df..d1fba61 100644 --- a/src/modules/client/pages/MyCertificates.jsx +++ b/src/modules/client/pages/MyCertificates.jsx @@ -30,7 +30,12 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeI a.click(); URL.revokeObjectURL(url); } catch { - toast.error("Could not download certificate."); + toast("Could not download certificate.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setDownloading(false); } diff --git a/src/modules/client/pages/Notifications.jsx b/src/modules/client/pages/Notifications.jsx index 6061cfe..36d1ca0 100644 --- a/src/modules/client/pages/Notifications.jsx +++ b/src/modules/client/pages/Notifications.jsx @@ -141,8 +141,18 @@ export default function Notifications() { const ok = await clearAll(); setClearing(false); setClearOpen(false); - if (ok) toast.success("All notifications cleared."); - else toast.error("Could not clear notifications."); + if (ok) toast("All notifications cleared.", { + action: { + label: "Close", + onClick: () => {} + } + }); + else toast("Could not clear notifications.", { + action: { + label: "Close", + onClick: () => {} + } + }); } const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1; @@ -176,7 +186,7 @@ export default function Notifications() { Mark all as read )} - + */}
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx index 7c39caa..6c1e737 100644 --- a/src/modules/client/pages/PlanList.jsx +++ b/src/modules/client/pages/PlanList.jsx @@ -385,12 +385,22 @@ export default function PlanList() { setRefundLoading(true); try { const { data } = await api.post("/client/tiers/checkout/refund"); - toast.success(data.message ?? "Refund processed. Your access has been revoked."); + toast(data.message ?? "Refund processed. Your access has been revoked.", { + action: { + label: "Close", + onClick: () => {} + } + }); setRefundPlan(null); resetMyTier(); getMyTier(); } catch (err) { - toast.error(err?.response?.data?.message ?? "Refund failed. Please try again."); + toast(err?.response?.data?.message ?? "Refund failed. Please try again.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setRefundLoading(false); } diff --git a/src/modules/client/pages/Profile.jsx b/src/modules/client/pages/Profile.jsx index 73b6a6d..3803987 100644 --- a/src/modules/client/pages/Profile.jsx +++ b/src/modules/client/pages/Profile.jsx @@ -78,7 +78,7 @@ function resolveTierBadge(myTier) { colorKey: category.color ?? "green", label: category.badge_label ?? category.name ?? tier, description: "", - information: "", + information: category.description ?? "", }; } } @@ -134,7 +134,12 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badg a.click(); URL.revokeObjectURL(url); } catch { - toast.error("Could not download certificate."); + toast("Could not download certificate.", { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setDownloading(false); } diff --git a/src/modules/client/pages/UnitList.jsx b/src/modules/client/pages/UnitList.jsx index 710c1d7..4da5602 100644 --- a/src/modules/client/pages/UnitList.jsx +++ b/src/modules/client/pages/UnitList.jsx @@ -507,7 +507,12 @@ const UnitList = () => { useEffect(() => { if (!completedTasks.length) return; completedTasks.forEach((t) => { - toast.success(`"${t.task_name}" automatically turned in!`); + toast(`"${t.task_name}" automatically turned in!`, { + action: { + label: "Close", + onClick: () => {} + } + }); }); clearCompletedTasks(); }, [completedTasks]); diff --git a/src/modules/client/pages/ViewTask.jsx b/src/modules/client/pages/ViewTask.jsx index 41853f5..3494d41 100644 --- a/src/modules/client/pages/ViewTask.jsx +++ b/src/modules/client/pages/ViewTask.jsx @@ -114,19 +114,7 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite

Requirements

- {items.map(({ key, label, icon, getValue }) => { - const isProvided = reqTypes.includes(key); - if (!isProvided) { - return ( -
-
- {icon} - {label} -
- Not provided -
- ); - } + {provided.map(({ key, label, icon, getValue }) => { const { done, total, binary } = getValue(); const complete = total > 0 && done >= total; return ( @@ -152,9 +140,14 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
Overall progress - {completedCount} / {provided.length} done + {provided.length > 0 && completedCount >= provided.length && ( + Completed + )}
- + 0 && completedCount >= provided.length ? "[&>div]:bg-green-500" : ""}`} + />
); @@ -322,7 +315,12 @@ const ViewTask = () => { } if (!uploadedFiles.length) { - toast.error('No files were uploaded successfully.'); + toast('No files were uploaded successfully.', { + action: { + label: "Close", + onClick: () => {} + } + }); return; } @@ -336,7 +334,12 @@ const ViewTask = () => { setNote(''); setUploadState({ files: [], isUploading: false }); } catch (err) { - toast.error('Failed to submit. Please try again.'); + toast('Failed to submit. Please try again.', { + action: { + label: "Close", + onClick: () => {} + } + }); } finally { setSubmitting(false); } @@ -410,7 +413,7 @@ const ViewTask = () => {
{/* ── Left ──────────────────────────────────────────────── */} -
+
{/* Task header card */}
@@ -448,10 +451,6 @@ const ViewTask = () => { {/* Requirements section */} {!isResolving && requirements.length > 0 && ( <> -
-

Requirements

-
- {/* visit_link */} {visitLinkReqs.length > 0 && ( {/* Resources */}
-

Resources

+

Resources

  • Philpro Learnings @@ -39,7 +39,7 @@ export default function Footer() { {/* Company */}
    -

    Company

    +

    Company

    • Philpro Learnings @@ -58,7 +58,7 @@ export default function Footer() { {/* Socials */}
      -

      Socials

      +

      Socials

      • Philpro Learnings diff --git a/src/modules/public/pages/LandingPage.jsx b/src/modules/public/pages/LandingPage.jsx index 676caf0..2cf2aef 100644 --- a/src/modules/public/pages/LandingPage.jsx +++ b/src/modules/public/pages/LandingPage.jsx @@ -49,7 +49,7 @@ function LandingPage() { Alpha Testing
      -

      +

      Fueling Growth, Elevate your performance

      Access the application, Achieve the transformation.

      @@ -86,7 +86,7 @@ function LandingPage() { /> To-do 3 @@ -100,7 +100,7 @@ function LandingPage() { /> Pending 8 @@ -114,7 +114,7 @@ function LandingPage() { /> Completed 20 @@ -141,7 +141,7 @@ function LandingPage() {
{/* Call to Action */} -
+
“The Sales Training and Recruitment (STAR) makes building a winning sales team simple. From hiring the right people to fast-tracking their skills, it combines smart recruitment, clear onboarding, and practical training to create confident, high-performing professionals.”
@@ -153,15 +153,15 @@ function LandingPage() { {/* For sales, why choose us? */}
-

Hire Smarter

+

Hire Smarter

Find the right talent faster with a streamlined recruitment process.

-

Train Better

+

Train Better

Equip every recruit with clear onboarding, mandatory modules, and practical sales training.

-

Grow

+

Grow

Build confident professionals, reduce turnover, and boost long-term sales performance.

@@ -170,7 +170,7 @@ function LandingPage() {
-

Frequently Asked Questions

+

Frequently Asked Questions

Here are useful questions.

@@ -187,14 +187,14 @@ function LandingPage() {
-

Contact Us

+

Contact Us

Find the right talent faster with a streamlined recruitment process.

{ContactData.map(({ id, icon: Icon, label, value }) => (
@@ -205,7 +205,12 @@ function LandingPage() {
{/* Closing Remarks */} -
+
Ready to supercharge? {
} Start by leveraging your limits.
@@ -228,7 +233,7 @@ function LandingPage() {
- ) + ); } export default LandingPage; diff --git a/src/routes/PublicRoute.jsx b/src/routes/PublicRoute.jsx index e467dd0..b8ec6c9 100644 --- a/src/routes/PublicRoute.jsx +++ b/src/routes/PublicRoute.jsx @@ -1,6 +1,6 @@ import { Navigate, Outlet } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' -import { useEffect } from 'react' +import { getRoleHomePath } from '../utils/roleRedirect.util' export default function PublicRoute() { const { user, loading } = useAuth() // ← just loading and user @@ -8,12 +8,7 @@ export default function PublicRoute() { if (loading) return null // ← only block on initial cold load if (user) { - switch (user.acc_type) { - case 'admin': return - case 'user': return - case 'staff': return - default: return - } + return } return diff --git a/src/routes/RequiredPasswordChange.jsx b/src/routes/RequiredPasswordChange.jsx index 8561458..8772c1a 100644 --- a/src/routes/RequiredPasswordChange.jsx +++ b/src/routes/RequiredPasswordChange.jsx @@ -1,6 +1,7 @@ // RequirePasswordChange.jsx import { Navigate, Outlet } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' +import { getRoleHomePath } from '../utils/roleRedirect.util' export default function RequirePasswordChange() { const { user, loading } = useAuth() @@ -12,12 +13,7 @@ export default function RequirePasswordChange() { // User is logged in but doesn't need to change password → send to dashboard if (!user.must_change_password) { - switch (user.acc_type) { - case 'admin': return - case 'staff': return - case 'client': return - default: return - } + return } // User is logged in AND must change password → allow through diff --git a/src/utils/roleRedirect.util.js b/src/utils/roleRedirect.util.js new file mode 100644 index 0000000..099706d --- /dev/null +++ b/src/utils/roleRedirect.util.js @@ -0,0 +1,12 @@ +// utils/roleRedirect.util.js +// Single source of truth for "where does this account type land after auth." +// acc_type is only ever 'admin' | 'staff' | 'user' (see users.mdl.js) — there is +// no 'client' value, despite some older call sites checking for one. +export function getRoleHomePath(user) { + switch (user?.acc_type) { + case 'admin': return '/admin' + case 'staff': return '/staff' + case 'user': return user.needs_intro ? '/intro' : '/dashboard' + default: return '/dashboard' + } +}