mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user