add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+76
View File
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminAchievementsContext = createContext(null);
export function useAdminAchievements() {
const ctx = useContext(AdminAchievementsContext);
if (!ctx) throw new Error("useAdminAchievements must be used inside AdminAchievementsProvider");
return ctx;
}
export function AdminAchievementsProvider({ children }) {
const [achievements, setAchievements] = useState([]);
const [achievement, setAchievement] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
const fetchAchievements = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/achievements");
setAchievements(data.data ?? []);
return data.data;
}), [request]);
const fetchAchievement = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/achievements/${id}`);
setAchievement(data.data ?? null);
return data.data;
}), [request]);
const createAchievement = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/achievements", payload);
toast.success("Achievement created.");
return data.data;
}), [request]);
const updateAchievement = useCallback((id, payload) =>
request(async () => {
const { data } = await api.put(`/admin/achievements/${id}`, payload);
setAchievements((prev) =>
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.");
return data.data;
}), [request, achievement]);
const deleteAchievement = useCallback((id) =>
request(async () => {
await api.delete(`/admin/achievements/${id}`);
setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id)));
toast.success("Achievement deleted.");
return true;
}), [request]);
return (
<AdminAchievementsContext.Provider value={{
achievements, achievement, loading,
fetchAchievements, fetchAchievement,
createAchievement, updateAchievement, deleteAchievement,
}}>
{children}
</AdminAchievementsContext.Provider>
);
}
+102 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -20,6 +20,16 @@ const PAGINATION_INIT = {
hasNextPage: false,
};
// No Redis yet — these caches are plain in-memory (per browser tab, cleared on
// refresh) to absorb the repeated open/close traffic pickers like
// AssetPickerSheet generate against Postgres and the media-token endpoint.
const LIST_CACHE_TTL_MS = 20_000; // short: just enough to survive rapid open/close flapping
const MEDIA_TOKEN_TTL_MS = 30 * 60 * 1000; // mirrors TOKEN_TTL_SEC in media.controller.js
const MEDIA_TOKEN_REFRESH_MARGIN_MS = 2 * 60 * 1000; // re-mint a bit before real expiry
const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
export function AssetsProvider({ children }) {
const [assets, setAssets] = useState([]);
const [attributes, setAttributes] = useState([]);
@@ -27,6 +37,32 @@ export function AssetsProvider({ children }) {
const [selectedAsset, setSelectedAsset] = useState(null);
const [loading, setLoading] = useState(false);
// { [asset_id]: { token, thumbnail_url, issuedAt } } — shared across every
// picker instance so tokens survive sheet open/close for their full TTL.
const [mediaTokens, setMediaTokens] = useState({});
const mediaTokensRef = useRef({});
const listCacheRef = useRef(new Map());
const invalidateListCache = () => listCacheRef.current.clear();
// Seeds mediaTokens from stream_token/thumbnail_url fields the backend now
// embeds directly in S3 rows of GET /admin/assets — so getMediaTokens (called
// right after fetchAssets by pickers/tables) finds them already cached and
// skips the batch round-trip instead of re-requesting tokens it just got.
const seedMediaTokensFromRows = (rows = []) => {
const issuedAt = Date.now();
const next = {};
for (const row of rows) {
if (row.stream_token) {
next[String(row.asset_id)] = { token: row.stream_token, thumbnail_url: row.thumbnail_url ?? null, issuedAt };
}
}
if (Object.keys(next).length) {
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
}
};
const request = useCallback(async (fn) => {
setLoading(true);
try {
@@ -41,9 +77,22 @@ export function AssetsProvider({ children }) {
}, []);
// ─── GET /api/admin/assets ────────────────────────────────────────────────
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
// a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
// same query within the TTL window. Pass force: true to bypass the cache.
const fetchAssets = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
const key = cacheKeyFor("assets", { page, limit, filters, sort });
const cached = listCacheRef.current.get(key);
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
setAssets(cached.assets);
setPagination(cached.pagination);
setAttributes(cached.attributes);
seedMediaTokensFromRows(cached.assets);
return Promise.resolve(cached.raw);
}
return request(async () => {
const { data } = await api.get("/admin/assets", {
params: {
page, limit,
@@ -57,12 +106,53 @@ export function AssetsProvider({ children }) {
setAssets(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
seedMediaTokensFromRows(result?.data);
listCacheRef.current.set(key, {
assets: result?.data ?? [],
pagination: result?.pagination ?? PAGINATION_INIT,
attributes: result.attributes,
raw: data.data,
fetchedAt: Date.now(),
});
return data.data;
}),
});
},
[request]
);
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
// Skips any asset_id whose cached token is still within its TTL (minus a
// safety margin) instead of re-minting a fresh JWT/presigned URL every time
// a picker reopens. Shared across all picker instances via context state.
const getMediaTokens = useCallback((assetIds = []) => {
const now = Date.now();
const missing = assetIds
.map(String)
.filter((id) => {
const cached = mediaTokensRef.current[id];
return !cached || (now - cached.issuedAt) > (MEDIA_TOKEN_TTL_MS - MEDIA_TOKEN_REFRESH_MARGIN_MS);
});
if (!missing.length) return Promise.resolve(mediaTokensRef.current);
return api.post("/admin/media/tokens", { asset_ids: missing }).then(({ data }) => {
const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const issuedAt = Date.now();
const next = {};
for (const [id, token] of Object.entries(tokens)) {
next[id] = { token, thumbnail_url: thumbnails[id] ?? null, issuedAt };
}
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
return mediaTokensRef.current;
});
}, []);
// ─── GET /api/admin/assets/:assetId ───────────────────────────────────────
const fetchAsset = useCallback(
(assetId) =>
@@ -110,6 +200,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => [asset, ...prev]);
invalidateListCache();
toast.success("Asset uploaded successfully.");
}
return res.data;
@@ -135,6 +226,7 @@ export function AssetsProvider({ children }) {
if (asset) {
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
setSelectedAsset(asset);
invalidateListCache();
toast.success("Asset updated successfully.");
}
return res.data;
@@ -151,6 +243,7 @@ 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.");
return res.data;
}),
@@ -165,6 +258,7 @@ export function AssetsProvider({ children }) {
data: { ids, deletedBy },
});
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast.success(`${ids.length} asset(s) archived.`);
return res.data;
}),
@@ -179,6 +273,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
invalidateListCache();
toast.success("Asset restored.");
}
return res.data;
@@ -192,6 +287,7 @@ export function AssetsProvider({ children }) {
request(async () => {
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.`);
return res.data;
}),
@@ -215,6 +311,8 @@ export function AssetsProvider({ children }) {
pagination,
selectedAsset,
loading,
mediaTokens,
getMediaTokens,
setPagination,
setSelectedAsset,
fetchAssets,
+42 -2
View File
@@ -230,8 +230,22 @@ export function CoursesProvider({ children }) {
// =========================================================================
const fetchUnits = useCallback(
(courseId, params = {}) =>
paginatedGet(`${BASE}/${courseId}/units`, setUnits, params),
(courseId, params = {}) => {
const mergeQuiz = (incoming) =>
setUnits((prev) => {
if (!prev.length) return incoming;
const prevMap = Object.fromEntries(prev.map((u) => [u.unit_id, u]));
return incoming.map((u) => {
const old = prevMap[u.unit_id];
return {
...u,
quiz: u.quiz ?? old?.quiz ?? null,
quiz_id: u.quiz_id ?? old?.quiz_id ?? null,
};
});
});
return paginatedGet(`${BASE}/${courseId}/units`, mergeQuiz, params);
},
[paginatedGet],
);
@@ -470,6 +484,18 @@ export function CoursesProvider({ children }) {
[request],
);
const bulkSyncQuizQuestions = useCallback(
(courseId, unitId, quizId, questions, updatedBy) =>
request(async () => {
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.");
return data;
}),
[request],
);
const deleteQuizQuestion = useCallback(
(courseId, unitId, quizId, questionId, deletedBy) =>
request(async () => {
@@ -796,6 +822,18 @@ export function CoursesProvider({ children }) {
[request],
);
const bulkSyncAssessmentQuestions = useCallback(
(courseId, assessmentId, questions, updatedBy) =>
request(async () => {
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.");
return data;
}),
[request],
);
const deleteAssessmentQuestion = useCallback(
(courseId, assessmentId, questionId, deletedBy) =>
request(async () => {
@@ -1061,6 +1099,7 @@ export function CoursesProvider({ children }) {
updateQuizQuestion,
deleteQuizQuestion,
bulkArchiveQuizQuestions,
bulkSyncQuizQuestions,
// ── quiz question archives & restore ───────────────────────────────────
fetchArchivedQuizQuestion,
@@ -1107,6 +1146,7 @@ export function CoursesProvider({ children }) {
updateAssessmentQuestion,
deleteAssessmentQuestion,
bulkArchiveAssessmentQuestions,
bulkSyncAssessmentQuestions,
// ── assessment question archives & restore ─────────────────────────────
fetchArchivedAssessmentQuestion,
@@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminEmailBroadcastContext = createContext(null);
export function useAdminEmailBroadcasts() {
const ctx = useContext(AdminEmailBroadcastContext);
if (!ctx) throw new Error("useAdminEmailBroadcasts must be used inside AdminEmailBroadcastProvider");
return ctx;
}
export function AdminEmailBroadcastProvider({ children }) {
const [broadcasts, setBroadcasts] = useState([]);
const [broadcast, setBroadcast] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
// Silent variant for polling — no loading spinner flicker, no toast noise on transient failures.
const fetchBroadcastsQuiet = useCallback(async () => {
try {
const { data } = await api.get("/admin/email-broadcasts");
setBroadcasts(data.data ?? []);
return data.data;
} catch { return null; }
}, []);
const fetchBroadcasts = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/email-broadcasts");
setBroadcasts(data.data ?? []);
return data.data;
}), [request]);
const fetchBroadcast = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/email-broadcasts/${id}`);
setBroadcast(data.data ?? null);
return data.data;
}), [request]);
const createBroadcast = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/email-broadcasts", payload);
toast.success(data.message ?? "Broadcast queued.");
return data.data;
}), [request]);
const cancelBroadcast = useCallback((id) =>
request(async () => {
const { data } = await api.patch(`/admin/email-broadcasts/${id}/cancel`);
setBroadcasts((prev) => prev.map((b) => (String(b.email_broadcast_id) === String(id) ? data.data : b)));
toast.success("Broadcast canceled.");
return data.data;
}), [request]);
return (
<AdminEmailBroadcastContext.Provider value={{
broadcasts, broadcast, loading,
fetchBroadcasts, fetchBroadcastsQuiet, fetchBroadcast,
createBroadcast, cancelBroadcast,
}}>
{children}
</AdminEmailBroadcastContext.Provider>
);
}
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminEmailTemplateContext = createContext(null);
export function useAdminEmailTemplates() {
const ctx = useContext(AdminEmailTemplateContext);
if (!ctx) throw new Error("useAdminEmailTemplates must be used inside AdminEmailTemplateProvider");
return ctx;
}
export function AdminEmailTemplateProvider({ children }) {
const [templates, setTemplates] = useState([]);
const [template, setTemplate] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
const fetchTemplates = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/email-templates");
setTemplates(data.data ?? []);
return data.data;
}), [request]);
const fetchTemplate = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/email-templates/${id}`);
setTemplate(data.data ?? null);
return data.data;
}), [request]);
const createTemplate = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/email-templates", payload);
toast.success("Email template created.");
return data.data;
}), [request]);
const updateTemplate = useCallback((id, payload) =>
request(async () => {
const { data } = await api.put(`/admin/email-templates/${id}`, payload);
setTemplates((prev) =>
prev.map((t) => (String(t.email_template_id) === String(id) ? data.data : t))
);
if (template && String(template.email_template_id) === String(id)) setTemplate(data.data);
toast.success("Email template updated.");
return data.data;
}), [request, template]);
const deleteTemplate = useCallback((id) =>
request(async () => {
await api.delete(`/admin/email-templates/${id}`);
setTemplates((prev) => prev.filter((t) => String(t.email_template_id) !== String(id)));
toast.success("Email template deleted.");
return true;
}), [request]);
return (
<AdminEmailTemplateContext.Provider value={{
templates, template, loading,
fetchTemplates, fetchTemplate,
createTemplate, updateTemplate, deleteTemplate,
}}>
{children}
</AdminEmailTemplateContext.Provider>
);
}
@@ -0,0 +1,221 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const NotificationBroadcastsContext = createContext(null);
export function useNotificationBroadcasts() {
const ctx = useContext(NotificationBroadcastsContext);
if (!ctx) throw new Error("useNotificationBroadcasts must be used within a NotificationBroadcastsProvider");
return ctx;
}
// ─── Initial States ────────────────────────────────────────────────────────────
const PAGINATION_INIT = {
page: 1,
limit: 10,
totalRecords: 0,
totalPages: 0,
hasPrevPage: false,
hasNextPage: false,
};
export function NotificationBroadcastsProvider({ children }) {
const [broadcasts, setBroadcasts] = useState([]);
const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try {
return await fn();
} catch (err) {
const message = err?.response?.data?.message ?? "Something went wrong.";
toast.error(message);
return null;
} finally {
setLoading(false);
}
}, []);
// ─── GET /api/admin/notification-broadcasts ────────────────────────────────
const fetchBroadcasts = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
const { data } = await api.get("/admin/notification-broadcasts", {
params: {
page, limit,
filters: filters.length ? JSON.stringify(filters) : undefined,
sort: sort.length ? JSON.stringify(sort) : undefined,
},
});
const result = data?.data;
setBroadcasts(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
return data.data;
}),
[request]
);
// ─── GET /api/admin/notification-broadcasts/:broadcastId ──────────────────
const fetchBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.get(`/admin/notification-broadcasts/${broadcastId}`);
setSelectedBroadcast(res.data?.data?.data ?? null);
return res.data;
}),
[request]
);
// ─── GET /api/admin/notification-broadcasts/archived ───────────────────────
const fetchArchivedBroadcasts = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
const { data } = await api.get("/admin/notification-broadcasts/archived", {
params: {
page, limit,
filters: filters.length ? JSON.stringify(filters) : undefined,
sort: sort.length ? JSON.stringify(sort) : undefined,
},
});
const final_data = data?.data;
setBroadcasts(final_data?.data ?? []);
setPagination(final_data?.pagination ?? PAGINATION_INIT);
setAttributes(final_data.attributes);
return data;
}),
[request]
);
// ─── POST /api/admin/notification-broadcasts ───────────────────────────────
const createBroadcast = useCallback(
(fields) =>
request(async () => {
const res = await api.post("/admin/notification-broadcasts", fields);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => [broadcast, ...prev]);
toast.success("Notification broadcast created.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId ────────────────
const updateBroadcast = useCallback(
(broadcastId, fields) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}`, fields);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast.success("Notification broadcast updated.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/send ───────────
const sendBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/send`);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast.success("Notification broadcast sent.");
}
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/notification-broadcasts/:broadcastId ───────────────
const archiveBroadcast = useCallback(
(broadcastId, { deletedBy } = {}) =>
request(async () => {
const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}`, {
data: { deletedBy },
});
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
toast.success("Notification broadcast archived.");
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/notification-broadcasts/bulk ────────────────────────
const archiveBroadcasts = useCallback(
({ ids }, { deletedBy } = {}) =>
request(async () => {
const res = await api.delete("/admin/notification-broadcasts/bulk", {
data: { ids, deletedBy },
});
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
toast.success(`${ids.length} notification broadcast(s) archived.`);
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/restore ────────
const restoreBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/restore`);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
toast.success("Notification broadcast restored.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/bulk-restore ─────────────────
const restoreBroadcasts = useCallback(
({ ids }) =>
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.`);
return res.data;
}),
[request]
);
return (
<NotificationBroadcastsContext.Provider value={{
broadcasts,
attributes,
pagination,
selectedBroadcast,
loading,
setPagination,
setSelectedBroadcast,
fetchBroadcasts,
fetchBroadcast,
fetchArchivedBroadcasts,
createBroadcast,
updateBroadcast,
sendBroadcast,
archiveBroadcast,
archiveBroadcasts,
restoreBroadcast,
restoreBroadcasts,
}}>
{children}
</NotificationBroadcastsContext.Provider>
);
}
+198 -13
View File
@@ -1,8 +1,26 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button";
import { useProfile } from "@/contexts/ProfileProvider";
const ClientAdvertisementsContext = createContext(null);
// After this many clicks on the same ad in one browser session, further clicks
// are held behind a confirmation dialog instead of following through straight
// away — guards against accidental/rapid repeat clicks inflating ad clicks.
const CLICK_LIMIT = 3;
const CLICK_STORAGE_KEY = "ad_click_counts";
function loadClickCounts() {
try {
return JSON.parse(sessionStorage.getItem(CLICK_STORAGE_KEY)) || {};
} catch {
return {};
}
}
export function useClientAdvertisements() {
const ctx = useContext(ClientAdvertisementsContext);
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
@@ -10,28 +28,104 @@ export function useClientAdvertisements() {
}
export function ClientAdvertisementsProvider({ children }) {
// Keyed by type so hero + popup (or any combo) can be fetched independently
// without clobbering each other: { hero: {...}, popup: {...} }
const navigate = useNavigate();
const { profile, getProfile, updateProfile } = useProfile();
// Keyed by placement so multiple slots on the same page (e.g. dashboard.hero +
// dashboard.popup) can be fetched independently without clobbering each other.
const [advertisements, setAdvertisements] = useState({});
const [loading, setLoading] = useState({});
const [clickCounts, setClickCounts] = useState(loadClickCounts);
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false);
// ─── GET /api/client/advertisements/active?type=hero ──────────────────────
// Ad fetches must know the real preference before deciding visibility — never
// assume "show" as a default just because profile hasn't loaded yet. profileRef
// always holds the latest profile so even a stale fetch-function reference
// (captured by a page's mount-only effect) reads current data when it runs.
const profileRef = useRef(profile);
useEffect(() => { profileRef.current = profile; }, [profile]);
const profileFetchRef = useRef(null);
const ensureProfile = useCallback(async () => {
if (profileRef.current) return profileRef.current;
if (!profileFetchRef.current) {
profileFetchRef.current = getProfile().finally(() => { profileFetchRef.current = null; });
}
const fresh = await profileFetchRef.current;
profileRef.current = fresh;
return fresh;
}, [getProfile]);
// Popups are gated separately from hero/banner/sidebar so "Don't show this
// ad again" only ever touches popups, per the Settings → Advertisements toggles.
const resolveVisibility = (profileData, ad) => {
if (!ad) return ad;
const showPopupAds = profileData?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profileData?.personal_info?.show_other_ads ?? true;
const hidden = ad.type === "popup" ? !showPopupAds : !showOtherAds;
return hidden ? null : ad;
};
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────
const getActiveAdvertisement = useCallback(
async (type) => {
setLoading((prev) => ({ ...prev, [type]: true }));
async (placement) => {
setLoading((prev) => ({ ...prev, [placement]: true }));
try {
const { data } = await api.get("/client/advertisements/active", { params: { type } });
const ad = data?.data?.data ?? null;
setAdvertisements((prev) => ({ ...prev, [type]: ad }));
const [currentProfile, { data }] = await Promise.all([
ensureProfile(),
api.get("/client/advertisements/active", { params: { placement } }),
]);
const ad = resolveVisibility(currentProfile, data?.data?.data ?? null);
setAdvertisements((prev) => ({ ...prev, [placement]: ad }));
return ad;
} catch {
setAdvertisements((prev) => ({ ...prev, [type]: null }));
setAdvertisements((prev) => ({ ...prev, [placement]: null }));
return null;
} finally {
setLoading((prev) => ({ ...prev, [type]: false }));
setLoading((prev) => ({ ...prev, [placement]: false }));
}
},
[]
[ensureProfile]
);
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────
// Resolves several placements in one round-trip — use for any page that
// needs more than one simultaneous slot.
const getActiveAdvertisements = useCallback(
async (placements) => {
if (!placements?.length) return {};
setLoading((prev) => {
const next = { ...prev };
placements.forEach((p) => { next[p] = true; });
return next;
});
try {
const [currentProfile, { data }] = await Promise.all([
ensureProfile(),
api.get("/client/advertisements/active-batch", {
params: { placements: placements.join(",") },
}),
]);
const raw = data?.data?.data ?? {};
const result = Object.fromEntries(
Object.entries(raw).map(([placement, ad]) => [placement, resolveVisibility(currentProfile, ad)])
);
setAdvertisements((prev) => ({ ...prev, ...result }));
return result;
} catch {
const fallback = Object.fromEntries(placements.map((p) => [p, null]));
setAdvertisements((prev) => ({ ...prev, ...fallback }));
return fallback;
} finally {
setLoading((prev) => {
const next = { ...prev };
placements.forEach((p) => { next[p] = false; });
return next;
});
}
},
[ensureProfile]
);
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
@@ -44,14 +138,105 @@ export function ClientAdvertisementsProvider({ children }) {
[]
);
// Tracks the click then follows the CTA link (external → new tab, internal → router nav).
const goToCta = useCallback(
(ad, cta) => {
trackClick(ad?.advertisement_id);
if (!cta?.link) return;
if (/^https?:\/\//.test(cta.link)) {
window.open(cta.link, "_blank", "noopener,noreferrer");
} else {
navigate(cta.link);
}
},
[navigate, trackClick]
);
// ─── CTA click guard ────────────────────────────────────────────────────
// Shared onCtaClick for every ad block (Banner/Hero/Sidebar/Popup). Counts
// clicks per advertisement for the browser session; once the limit is
// exceeded, hold the click behind a confirmation dialog instead of
// silently continuing.
const handleAdCtaClick = useCallback(
(ad, cta) => {
const id = ad?.advertisement_id;
if (!id) {
goToCta(ad, cta);
return;
}
const nextCount = (clickCounts[id] || 0) + 1;
setClickCounts((prev) => {
const next = { ...prev, [id]: nextCount };
sessionStorage.setItem(CLICK_STORAGE_KEY, JSON.stringify(next));
return next;
});
if (nextCount <= CLICK_LIMIT) {
goToCta(ad, cta);
} else {
setPendingClick({ ad, cta });
}
},
[clickCounts, goToCta]
);
const confirmPendingClick = () => {
if (pendingClick) goToCta(pendingClick.ad, pendingClick.cta);
setPendingClick(null);
};
// ─── "Don't show this ad again" (popups only) ──────────────────────────
// Persists the preference to the account (so it follows across devices),
// then shows a one-time confirmation pointing at where to turn it back on.
const dismissPopupForever = useCallback(async () => {
const result = await updateProfile({ show_popup_ads: false });
if (result?.data) profileRef.current = result.data;
setDismissConfirmOpen(true);
}, [updateProfile]);
const goToAdSettings = () => {
setDismissConfirmOpen(false);
navigate("/settings");
};
return (
<ClientAdvertisementsContext.Provider value={{
advertisements,
loading,
getActiveAdvertisement,
getActiveAdvertisements,
trackClick,
handleAdCtaClick,
dismissPopupForever,
}}>
{children}
<ResponsiveModal
open={!!pendingClick}
onOpenChange={(open) => { if (!open) setPendingClick(null); }}
title="Continue to this ad?"
description="You've clicked this advertisement several times already. Confirm you'd like to keep visiting it."
footer={
<>
<Button variant="outline" onClick={() => setPendingClick(null)}>Cancel</Button>
<Button onClick={confirmPendingClick}>Continue</Button>
</>
}
/>
<ResponsiveModal
open={dismissConfirmOpen}
onOpenChange={setDismissConfirmOpen}
title="Popup ads turned off"
description="You won't see popup ads anymore. You can turn them back on anytime in Settings → Advertisements."
footer={
<>
<Button variant="outline" onClick={() => setDismissConfirmOpen(false)}>Got it</Button>
<Button onClick={goToAdSettings}>Go to Settings</Button>
</>
}
/>
</ClientAdvertisementsContext.Provider>
);
}
}
+21 -3
View File
@@ -12,10 +12,13 @@ export function useClientNotifications() {
return ctx;
}
const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 };
export function ClientNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const intervalRef = useRef(null);
const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
@@ -28,13 +31,14 @@ export function ClientNotificationProvider({ children }) {
}
}, []);
const fetchNotifications = useCallback(async () => {
const fetchNotifications = useCallback(async (page = 1, limit = 10) => {
setLoading(true);
try {
const res = await api.get("/client/notifications?limit=20");
const res = await api.get(`/client/notifications?limit=${limit}&page=${page}`);
const rows = res.data?.data?.notifications ?? [];
const pag = res.data?.data?.pagination ?? { page, limit, pages: 1, total: rows.length };
setNotifications(rows);
setUnseenCount(rows.filter(n => !n.seen).length);
setPagination({ ...pag, pages: Math.max(1, pag.pages) });
} catch {
// silent
} finally {
@@ -42,6 +46,18 @@ export function ClientNotificationProvider({ children }) {
}
}, []);
const clearAll = useCallback(async () => {
try {
await api.delete("/client/notifications/clear-all");
setNotifications([]);
setUnseenCount(0);
setPagination(DEFAULT_PAGINATION);
return true;
} catch {
return false;
}
}, []);
const markSeen = useCallback(async (id) => {
try {
await api.patch(`/client/notifications/${id}/seen`);
@@ -93,9 +109,11 @@ export function ClientNotificationProvider({ children }) {
notifications,
unseenCount,
loading,
pagination,
fetchNotifications,
markSeen,
markAllSeen,
clearAll,
accelerate,
decelerate,
}}>
+3 -6
View File
@@ -97,12 +97,10 @@ export function ClientTiersProvider({ children }) {
}, []);
// Returns { valid, code, type, value, discount, reason } from the server
const validatePromo = useCallback(async (plan_id, code, currency = null) => {
const validatePromo = useCallback(async (plan_id, code) => {
setPromoLoading(true);
try {
const payload = { plan_id, code };
if (currency) payload.currency = currency;
const { data } = await api.post('/client/tiers/promos/validate', payload);
const { data } = await api.post('/client/tiers/promos/validate', { plan_id, code });
return data.data ?? { valid: false, reason: 'No response from server.' };
} catch (err) {
return { valid: false, reason: err?.response?.data?.message ?? 'Invalid promo code.' };
@@ -112,12 +110,11 @@ export function ClientTiersProvider({ children }) {
}, []);
// Returns { payment_id, order_id, approval_url, amount, currency, ... } or null
const createOrder = useCallback(async (plan_id, promo_code = null, currency = null) => {
const createOrder = useCallback(async (plan_id, promo_code = null) => {
setCheckoutLoading(true);
try {
const payload = { plan_id };
if (promo_code) payload.promo_code = promo_code;
if (currency) payload.currency = currency;
const { data } = await api.post("/client/tiers/checkout/order", payload);
return data.data ?? null;
} catch (err) {
@@ -1,28 +0,0 @@
import { createContext, useContext, useState, useCallback } from 'react';
const STORAGE_KEY = 'currency-preference';
const CurrencyPreferenceContext = createContext(null);
export function CurrencyPreferenceProvider({ children }) {
const [currency, setCurrencyState] = useState(
() => localStorage.getItem(STORAGE_KEY) ?? 'USD'
);
const setCurrency = useCallback((code) => {
localStorage.setItem(STORAGE_KEY, code);
setCurrencyState(code);
}, []);
return (
<CurrencyPreferenceContext.Provider value={{ currency, setCurrency }}>
{children}
</CurrencyPreferenceContext.Provider>
);
}
export function useCurrencyPreference() {
const ctx = useContext(CurrencyPreferenceContext);
if (!ctx) throw new Error('useCurrencyPreference must be used inside CurrencyPreferenceProvider');
return ctx;
}
+4 -1
View File
@@ -30,9 +30,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
setProfileLoading(true);
try {
const { data } = await api.get(`${apiBase}/profile`);
setProfile(data.data ?? null);
const fresh = data.data ?? null;
setProfile(fresh);
return fresh;
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not load profile.");
return null;
} finally {
setProfileLoading(false);
}
+18 -15
View File
@@ -9,6 +9,7 @@ import { AdminTiersProvider } from "../AdminTiersContext";
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
import { ProfileProvider } from "../ProfileProvider";
import { AdvertisementsProvider } from "../AdminAdvertisementContext";
import { NotificationBroadcastsProvider } from "../AdminNotificationBroadcastContext";
import { AdminNotificationProvider } from "../AdminNotificationContext"
import { AdminCourseReadingProgressProvider } from "../AdminCourseReadingProgressContext";
@@ -19,21 +20,23 @@ export const AdminProvider = ({ children }) => {
<ProfileProvider apiBase="/admin">
<AssetsProvider>
<AdvertisementsProvider>
<UserProvider>
<UserGroupProvider>
<AdminTiersProvider>
<AdminCategoriesProvider>
<CoursesProvider>
<AdminCourseReadingProgressProvider>
<AdminTaskProvider>
{children}
</AdminTaskProvider>
</AdminCourseReadingProgressProvider>
</CoursesProvider>
</AdminCategoriesProvider>
</AdminTiersProvider>
</UserGroupProvider>
</UserProvider>
<NotificationBroadcastsProvider>
<UserProvider>
<UserGroupProvider>
<AdminTiersProvider>
<AdminCategoriesProvider>
<CoursesProvider>
<AdminCourseReadingProgressProvider>
<AdminTaskProvider>
{children}
</AdminTaskProvider>
</AdminCourseReadingProgressProvider>
</CoursesProvider>
</AdminCategoriesProvider>
</AdminTiersProvider>
</UserGroupProvider>
</UserProvider>
</NotificationBroadcastsProvider>
</AdvertisementsProvider>
</AssetsProvider>
</ProfileProvider>