- {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 ? (
+
+ ) : (
+
+ )}
+
+ {/* 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) => (
+ onCtaClick?.(ad, cta)}
+ >
+ {cta.label}
+
+ ))}
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ {/* Navigation and Progress — only meaningful with more than one slide */}
+ {ads.length > 1 && (
+
)}
- {ad.description && (
-
- {ad.description}
-
- )}
- {ctas.length > 0 && (
-
- {ctas.map((cta, i) => (
- onCtaClick?.(ad, cta)}
- >
- {cta.label}
-
- ))}
-
- )}
-
-
- {imageSrc ? (
-
- ) : (
-
- )}
-
+
);
}
@@ -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 && (
+
+ )}
+
+ {/* ── Step 2: OTP + new password ── */}
+ {step === 1 && (
+
+ )}
+
+
+ {/* 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 (
<>
+
+
+
+
+ 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 && (
-
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
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 (
+
+ )
+ }
+
+ 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 ? (
+
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 ? (
- { e.currentTarget.style.display = 'none'; }}
- />
- ) : (
-
-
{ 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={
<>
setViewModalOpen(false)}>Close
-
-
- Open Link
-
-
{unsubmitting ? "Removing…" : "Unsubmit"}
@@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
}
>
- {displayImage ? (
-
{ e.currentTarget.style.display = 'none'; }}
- />
- ) : (
-
-
{ 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.
-
-
-
setOpen(true)}
- >
-
- Delete account
-
-
-
-
-
-
- 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.
+//
+//
+//
setOpen(true)}
+// >
+//
+// Delete account
+//
+//
+//
+//
+//
+//
+// 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 = () => {
) : (
navigate(`/course/${courseId}/unit`)}
>
{hasCompleted
@@ -688,49 +706,58 @@ const CourseDetails = () => {
{/* Body */}
-
-
About this course
-
-
{course?.description ?? ""}
+
+
+
About this course
+
+
{course?.description ?? ""}
+
+
{/* Objectives */}
- {course?.objectives?.length > 0 && (
- <>
-
What you will learn
-
- {course.objectives.map((obj) => (
- {obj.text}
- ))}
-
- >
- )}
+
+ {course?.objectives?.length > 0 && (
+
+
What you will learn
+
+ {course.objectives.map((obj) => (
+ {obj.text}
+ ))}
+
+
+ )}
+
+
{/* Units — while content isn't ready, only Rewards is shown */}
- {course?.units?.length > 0 && (
- <>
-
- {contentNotReady ? "Rewards" : "Course content"}
-
-
- >
- )}
+
+ {course?.units?.length > 0 && (
+ <>
+
+ {contentNotReady ? "Rewards" : "Course content"}
+
+
+ >
+ )}
+
+
{/* Advertisement Sidebar */}
-
- {/* Product category chips */}
- {allCategories.length > 0 && (
-
- { setCategoryFilter("All"); setCurrentPage(1); }}
- >
- All
-
- {allCategories.map((cat) => (
- { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
- >
- {cat.name}
-
- ))}
-
- )}
-
{/* 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
)}
-
Clear all
-
+ */}
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 */}
{/* 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() {