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
@@ -1,10 +1,18 @@
import { Trophy, Clock } from "lucide-react";
import { Trophy, Clock, CheckCircle2 } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
/**
* Props:
* course — { title, ... } the completed course
* course — { title, pending_certificate, certificate, ... } the completed course
*/
const CourseCompleteBlock = ({ course }) => {
const { fmtDate } = useDateFormat();
const certificate = course?.certificate ?? null;
const pendingCert = course?.pending_certificate ?? null;
const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert;
return (
<div className="max-w-2xl mx-auto">
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
@@ -25,18 +33,33 @@ const CourseCompleteBlock = ({ course }) => {
You've passed all required units and the final assessment for this course.
</p>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
</p>
{isIssued ? (
<div className="flex items-start gap-2.5 rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3 text-left">
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-400">Certificate Issued</p>
<p className="text-xs text-muted-foreground">
Issued on {fmtDate(certificate.issued_at)}. View and download it from your Certificates page.
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
{isPending
? `Certificates are issued automatically every hour. Yours will be ready by ${fmtDate(pendingCert.issue_at)} — check your notifications.`
: "Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications."}
</p>
</div>
</div>
)}
</div>
</div>
);
};
export default CourseCompleteBlock;
export default CourseCompleteBlock;
@@ -179,18 +179,13 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
);
navigate(`/course/${info.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{course.title}
</h1>
@@ -242,18 +237,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<Button
onClick={() => {
if (!info?.course_id) return;
if (taskId && groupId && taskListId) {
// Task context — read inside ViewRequirement
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
);
} else {
// No task context — fall back to standalone course reader
navigate(`/course/${info.course_id}/unit`, {
state: allRead ? { seekFirstIncomplete: true } : {},
});
}
navigate(`/course/${info.course_id}/unit`, {
state: {
...(allRead ? { seekFirstIncomplete: true } : {}),
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
disabled={done || !info?.course_id}
>
@@ -147,10 +147,16 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
return (
<div
key={lesson.id}
onClick={() => !isFetching && navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { lesson } },
)}
onClick={() => {
if (isFetching || !info?.unit?.course?.course_id) return;
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: {
lessonId: info.lesson_id,
unitId: info.unit.unit_id,
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
@@ -194,18 +194,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit } }
);
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{unit.title}
</h1>
@@ -251,10 +246,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit: selected } },
)}
onClick={() => {
const info = details[selected?.reference_id];
if (!info?.course?.course_id) return;
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
disabled={
getProgress(selected ?? {}) >= 100 ||
locked[selected?.reference_id] ||
+106 -60
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2, Globe } from "lucide-react";
import { KeyRound, CreditCard, Mail, 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";
@@ -20,12 +20,10 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -285,74 +283,122 @@ function NewsletterSection() {
);
}
// ─── Currency Preference ─────────────────────────────────────────────────────
// ─── Advertisements ───────────────────────────────────────────────────────────
function CurrencySection() {
const { profile, getProfile } = useProfile();
const { setCurrency } = useCurrencyPreference();
const [currencies, setCurrencies] = useState([]);
const [localValue, setLocalValue] = useState("USD");
const [saving, setSaving] = useState(false);
const AD_OPTIONS = [
{
key: "show_popup_ads",
label: "Popup ads",
description: "Show promotional popups when you open pages like the dashboard.",
},
{
key: "show_other_ads",
label: "Other ads",
description: "Show banner, hero, and sidebar advertisements across the site.",
},
];
function AdvertisementsSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
const [confirmPopupOff, setConfirmPopupOff] = useState(false);
useEffect(() => {
getProfile();
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
// Sync local value when profile loads or changes
useEffect(() => {
if (profile?.preferred_currency) setLocalValue(profile.preferred_currency);
}, [profile?.preferred_currency]);
const showPopupAds = profile?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profile?.personal_info?.show_other_ads ?? true;
// No separate stored flag — "hidden" just means both underlying toggles are off,
// so it can never drift out of sync with them.
const hideAllAds = !showPopupAds && !showOtherAds;
const savedValue = profile?.preferred_currency ?? "USD";
const isDirty = localValue !== savedValue;
const handleToggle = async (key, value) => {
// Turning popup ads off also turns off other ads — confirm first since
// it's a bigger change than the switch being flipped suggests.
if (key === "show_popup_ads" && value === false) {
setConfirmPopupOff(true);
return;
}
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
const handleSave = async () => {
setSaving(true);
try {
await api.patch("/client/profile/currency", { currency: localValue });
setCurrency(localValue);
toast.success("Currency preference saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save preference.");
setLocalValue(savedValue); // revert on error
} finally {
setSaving(false);
const confirmTurnOffPopupAndOther = async () => {
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." });
}
};
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." });
}
};
return (
<div className="space-y-3">
<div className="space-y-1.5 max-w-xs">
<Label>Preferred currency</Label>
{!profile ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={localValue} onValueChange={setLocalValue} disabled={saving}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-xs text-muted-foreground">
Plans without localized prices always display in USD regardless of this setting.
</p>
<>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Hide all ads</p>
<p className="text-xs text-muted-foreground">
Turn off every advertisement across the platform, popups included.
</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch checked={hideAllAds} onCheckedChange={handleHideAllToggle} />
)}
</div>
<Separator className="mt-4" />
</div>
{AD_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? true}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < AD_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
{isDirty && (
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
)}
</div>
<AlertDialog open={confirmPopupOff} onOpenChange={setConfirmPopupOff}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Turn off popup ads?</AlertDialogTitle>
<AlertDialogDescription>
This will also turn off Other ads (banner, hero, and sidebar advertisements).
You can turn either back on here anytime.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmTurnOffPopupAndOther}>
Turn off both
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -446,8 +492,8 @@ export default function AccountSettings() {
<NewsletterSection />
</Section>
<Section icon={Globe} title="Currency Preference" description="Set the currency used to display plan prices across the platform.">
<CurrencySection />
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
<AdvertisementsSection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
+6 -45
View File
@@ -15,13 +15,10 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import {
ArrowLeft, BookOpen, CalendarDays, Check,
Globe, House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
function formatDuration(days, unit) {
@@ -69,8 +66,6 @@ const Checkout = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat();
const { fmtPlanPrice, resolvePlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token");
@@ -97,29 +92,11 @@ const Checkout = () => {
getProfile();
}, []);
// Seed currency from the user's stored preference when profile loads
useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
getMyTier();
if (!plans.length) getPlans();
}, [getMyTier, getPlans, plans.length]);
const handleCurrencyChange = async (newCurrency) => {
setCurrency(newCurrency);
setPromoResult(null);
setPromoCode("");
try {
await api.patch("/client/profile/currency", { currency: newCurrency });
} catch {
// silent — context + localStorage already updated
}
};
const plan = useMemo(
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
[plans, planId]
@@ -158,8 +135,9 @@ const Checkout = () => {
const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const { price: effectivePrice, currency: effectiveCurrency } = resolvePlanPrice(plan ?? {});
const subtotal = plan ? effectivePrice : 0;
const effectivePrice = plan ? Number(plan.price) : 0;
const effectiveCurrency = plan?.currency ?? 'USD';
const subtotal = effectivePrice;
const discount = promoResult?.discount ?? 0;
const total = Math.max(subtotal - discount, 0);
const duration = formatDuration(plan?.duration_days, plan?.duration_unit);
@@ -172,8 +150,7 @@ const Checkout = () => {
const handleApplyPromo = async () => {
if (!plan) return;
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase(), localeCurrency);
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
if (result?.valid) {
setPromoResult(result);
toast.success("Promo code applied.");
@@ -188,11 +165,9 @@ const Checkout = () => {
};
const handlePayPal = async () => {
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const order = await createOrder(
plan.plan_id,
promoResult?.code ?? null,
localeCurrency,
);
if (!order) return;
const approvalUrl = order.approval_url;
@@ -299,22 +274,8 @@ const Checkout = () => {
</div>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-2xl font-bold text-primary">
{fmtPlanPrice(plan)}
{fmtCurrency(effectivePrice, effectiveCurrency)}
</p>
{plan.prices?.length > 0 && (
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger className="h-8 w-28 text-xs">
<Globe className="size-3 mr-1 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={plan.currency}>{plan.currency}</SelectItem>
{plan.prices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
+64 -35
View File
@@ -25,6 +25,9 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -330,7 +333,7 @@ const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pending
<p className="text-sm font-semibold text-foreground">{courseTitle}</p>
</div>
<p>
Your certificate will be issued within <span className="font-medium text-foreground">45 minutes</span> after passing and will appear in your <span className="font-medium text-foreground">Certificates</span> page.
Your certificate will be issued within <span className="font-medium text-foreground">the hour</span> after passing and will appear in your <span className="font-medium text-foreground">Certificates</span> page.
</p>
</>
)}
@@ -503,6 +506,7 @@ const CourseDetails = () => {
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick } = useClientAdvertisements();
const [tierMap, setTierMap] = useState({});
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
@@ -526,9 +530,14 @@ const CourseDetails = () => {
getMyTier();
getCourse(courseId);
fetchCourseProgress(courseId);
getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]);
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
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
// private S3 assets so the badge preview works on this page.
useEffect(() => {
@@ -645,44 +654,64 @@ const CourseDetails = () => {
</div>
</div>
{/* Advertisement Banner */}
<div className="lg:container lg:mx-auto xs:px-6 lg:px-4">
{adLoading["course_details.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-0 lg:py-8">
<div className="flex flex-col gap-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 lg:text-lg">
<p>{course?.description ?? ""}</p>
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex flex-col gap-4 flex-1 min-w-0">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
{/* Objectives */}
{course?.objectives?.length > 0 && (
<>
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</>
)}
{/* Units */}
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">Course content</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
/>
</>
)}
</div>
{/* Objectives */}
{course?.objectives?.length > 0 && (
<>
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</>
)}
{/* Units */}
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">Course content</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
/>
</>
)}
{/* Advertisement Sidebar */}
<aside className="hidden lg:block w-72 shrink-0 sticky top-24 h-fit">
{adLoading["course_details.sidebar"] ? (
<SidebarSkeleton />
) : (
<Sidebar ad={sidebarAd} onCtaClick={handleAdCtaClick} />
)}
</aside>
</div>
</div>
</div>
+14
View File
@@ -18,6 +18,8 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -160,6 +162,7 @@ const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
@@ -180,8 +183,12 @@ const CoursesList = () => {
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => {});
getActiveAdvertisement("course_list.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bannerAd = advertisements["course_list.banner"] ?? null;
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
@@ -290,6 +297,13 @@ const CoursesList = () => {
</div>
)}
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{/* Course Grid */}
{coursesLoading ? (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
+17 -22
View File
@@ -4,7 +4,6 @@ import {
Users, Timer,
Tag, LockIcon, Check,
} from "lucide-react";
import { ThemeSwitcher } from "../components/ThemeSwitcher";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -131,13 +130,15 @@ const GroupsTable = ({ groups, onView }) => (
<TableHead className="w-10 text-center px-4">#</TableHead>
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4">Description</TableHead>
<TableHead className="px-4">Task Lists</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g, i) => {
const isDefault = g.group_code === 'NOGRP';
const taskListCount = Number(g.task_list_count ?? g.taskLists?.length ?? 0);
return (
<TableRow key={g.group_id}>
<TableCell className="text-center px-4 text-muted-foreground tabular-nums">
@@ -147,12 +148,15 @@ const GroupsTable = ({ groups, onView }) => (
<TableCell className="px-4">
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
</TableCell>
<TableCell className="px-4 text-muted-foreground">
<TableCell className="px-4 text-muted-foreground ">
{isDefault
? <span className="text-xs italic">Awaiting assignment by admin</span>
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
}
</TableCell>
<TableCell className="px-4">
{taskListCount}
</TableCell>
<TableCell className="px-4 text-right">
<Button size="sm" variant="outline" onClick={() => onView(g)}>
View
@@ -175,6 +179,7 @@ const GroupsTableSkeleton = () => (
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4 text-center whitespace-nowrap">Task Lists</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
@@ -185,6 +190,7 @@ const GroupsTableSkeleton = () => (
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-8 mx-auto" /></TableCell>
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
</TableRow>
))}
@@ -201,7 +207,7 @@ const Client = () => {
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick, dismissPopupForever } = useClientAdvertisements();
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
@@ -210,8 +216,8 @@ const Client = () => {
const userTier = myTier?.tier ?? "free";
const heroAd = advertisements.hero ?? null;
const popupAd = advertisements.popup ?? null;
const heroAd = advertisements["dashboard.hero"] ?? null;
const popupAd = advertisements["dashboard.popup"] ?? null;
// Show welcome toast on first registration
useEffect(() => {
@@ -234,24 +240,12 @@ const Client = () => {
// ── Resolve active hero + popup ads once on mount ────────────────────────
useEffect(() => {
getActiveAdvertisement("hero");
getActiveAdvertisement("popup").then((ad) => {
if (ad) setPopupOpen(true);
getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => {
if (result["dashboard.popup"]) setPopupOpen(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── CTA click — track then navigate ───────────────────────────────────────
const handleAdCtaClick = (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);
}
};
// Show only first 3
const featuredCourses = courses.slice(0, 3);
@@ -277,7 +271,7 @@ const Client = () => {
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-8 px-16">
{/* ── Hero Advertisement ── */}
{adLoading.hero ? (
{adLoading["dashboard.hero"] ? (
<HeroSkeleton />
) : (
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
@@ -342,6 +336,7 @@ const Client = () => {
open={popupOpen}
onOpenChange={setPopupOpen}
onCtaClick={handleAdCtaClick}
onDismissForever={dismissPopupForever}
/>
{/* ── Upsell Modal — only for locked courses ── */}
@@ -387,4 +382,4 @@ const Client = () => {
);
};
export default Client;
export default Client;
+3 -17
View File
@@ -1,27 +1,13 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Trophy, Shield, BookOpen, Award, BadgeCheck,
Medal, Flame, Zap, Target, Star, ArrowLeft,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { Trophy, BadgeCheck, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
const ACHIEVEMENT_ICONS = {
early_access: Star,
premium_first_time: BadgeCheck,
exclusive_first_time: Medal,
first_course_completed: BookOpen,
courses_completed_5: Flame,
courses_completed_10: Zap,
perfect_quiz_score: Target,
profile_completed: Shield,
first_referral: Award,
};
const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
export default function MyAchievements() {
@@ -71,7 +57,7 @@ export default function MyAchievements() {
) : (
<div className="rounded-xl border bg-card">
{sorted.map((item, i) => {
const Icon = ACHIEVEMENT_ICONS[item.key] ?? getFallbackIcon(item.type);
const Icon = LucideIcons[item.icon] ?? getFallbackIcon(item.type);
return (
<div key={item.achievement_id ?? i}>
<div className="flex items-center gap-4 p-4">
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Bell, LockIcon, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from "@/components/ui/pagination";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
import { NotificationIcon, timeAgo } from "@/components/generic/notificationDisplay";
import NotificationDetailDialog from "@/components/generic/NotificationDetailDialog";
import { toast } from "sonner";
const PAGE_SIZES = [10, 20, 50];
function ClearAllDialog({ open, onOpenChange, onConfirm, loading }) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all notifications</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all of your notifications. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Clear all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// Always rendered — even with 0 pages — so the footer stays visible as notifications come in.
function PaginationControls({ page, totalPages, onPage }) {
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
export default function Notifications() {
const navigate = useNavigate();
const {
notifications, unseenCount, loading, pagination,
fetchNotifications, markSeen, markAllSeen, clearAll,
} = useClientNotifications();
const [selected, setSelected] = useState(null);
const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false);
useEffect(() => { fetchNotifications(1, PAGE_SIZES[0]); }, [fetchNotifications]);
function handleClickNotification(n) {
if (!n.seen) markSeen(n.notification_id);
setSelected(n);
}
function handlePageChange(page) {
fetchNotifications(page, pagination.limit);
}
function handlePageSizeChange(value) {
fetchNotifications(1, Number(value));
}
async function handleClearAll() {
setClearing(true);
const ok = await clearAll();
setClearing(false);
setClearOpen(false);
if (ok) toast.success("All notifications cleared.");
else toast.error("Could not clear notifications.");
}
const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1;
const rangeEnd = Math.min(pagination.page * pagination.limit, pagination.total);
return (
<section className="mt-17 bg-muted min-h-full">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">Notifications</h1>
<Badge variant="outline" className="gap-1 text-xs">
<LockIcon className="h-3 w-3" /> Only you
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{unseenCount > 0 ? `${unseenCount} unread` : "You're all caught up."}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{unseenCount > 0 && (
<Button variant="outline" size="sm" onClick={markAllSeen}>
Mark all as read
</Button>
)}
<Button
variant="outline"
size="sm"
className="gap-1.5 text-destructive hover:text-destructive"
disabled={notifications.length === 0}
onClick={() => setClearOpen(true)}
>
<Trash2 className="size-3.5" />
Clear all
</Button>
</div>
</div>
<div className="rounded-2xl border bg-card overflow-hidden">
{loading && notifications.length === 0 ? (
<div className="p-4 space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-40 text-center">
<Bell className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium">No notifications yet</p>
<p className="text-xs text-muted-foreground">You'll see updates about your courses and account here.</p>
</div>
) : (
<ul>
{notifications.map((n, i) => (
<li key={n.notification_id}>
<button
onClick={() => handleClickNotification(n)}
className={cn(
"w-full text-left px-5 py-4 hover:bg-muted/50 transition-colors",
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.seen && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
<p className="text-sm font-medium truncate">{n.title}</p>
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[11px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
</div>
</div>
</button>
{i < notifications.length - 1 && <Separator />}
</li>
))}
</ul>
)}
</div>
{/* Footer stays visible even when the list is empty, so it's ready as notifications come in */}
<div className="flex items-center justify-between gap-3 flex-wrap px-1">
<p className="text-sm text-muted-foreground">
Showing {rangeStart}-{rangeEnd} of {pagination.total}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rows per page</span>
<Select value={String(pagination.limit)} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PaginationControls
page={pagination.page}
totalPages={pagination.pages}
onPage={handlePageChange}
/>
</div>
</div>
</div>
<NotificationDetailDialog
notification={selected}
onOpenChange={(isOpen) => { if (!isOpen) setSelected(null); }}
/>
<ClearAllDialog
open={clearOpen}
onOpenChange={setClearOpen}
onConfirm={handleClearAll}
loading={clearing}
/>
</section>
);
}
+15 -22
View File
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import {
Megaphone, BookOpen, Clock, Check,
BookOpen, Clock, Check,
Tag, LockIcon, Zap, RotateCcw,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
@@ -22,7 +22,8 @@ import { toast } from "sonner";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -104,7 +105,7 @@ const PlanSkeleton = () => (
const PREVIEW_COURSE_LIMIT = 2;
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtPlanPrice } = useCurrency();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
@@ -341,7 +342,8 @@ export default function PlanList() {
const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtDate } = useDateFormat();
const { fmtPlanPrice } = useCurrency();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false);
@@ -351,8 +353,12 @@ export default function PlanList() {
useEffect(() => {
getPlans();
getMyTier();
getActiveAdvertisement("plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
const bannerAd = advertisements["plans.banner"] ?? null;
useEffect(() => {
clearInterval(refundTimerRef.current);
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
@@ -397,24 +403,11 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */}
{/* <Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
<CardContent className="flex flex-col gap-4 py-20 md:flex-row md:items-center md:justify-between pl-10">
<div className="space-y-2">
<Badge>
<Megaphone /> Limited Time Offer
</Badge>
<div className="max-w-xl">
<h1 className="text-3xl font-bold line-clamp-3 leading-relaxed">
Upgrade Your Learning Journey
</h1>
<p className="mt-2 text-sm line-clamp-3 text-muted-foreground">
Unlock premium courses, certificates, and exclusive educational content.
Get access to industry-leading materials and grow your skills today.
</p>
</div>
</div>
</CardContent>
</Card> */}
{adLoading["plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{/* Section Header */}
<div className="text-center mt-6">
+2 -13
View File
@@ -10,9 +10,6 @@ import {
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -107,9 +104,8 @@ const ViewPlan = () => {
const { id } = useParams();
const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { profile, getProfile } = useProfile();
const { fmtPlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
const { getProfile } = useProfile();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
useEffect(() => {
getProfile();
@@ -117,13 +113,6 @@ const ViewPlan = () => {
if (!plans.length) getPlans();
}, [id]);
// Seed currency preference from the user's stored profile
useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
const loading = plansLoading;
+2 -2
View File
@@ -11,7 +11,6 @@ import GroupList from '../pages/GroupList'
import ViewTaskDetails from '../pages/ViewTaskDetails'
import ProfilePage from '../pages/Profile'
import Checkout from '../pages/Checkout'
import ViewRequirement from '../pages/ViewRequirement'
import EditProfile from '../pages/EditProfile'
import PlanList from '../pages/PlanList'
import ViewPlan from '../pages/ViewPlan'
@@ -20,6 +19,7 @@ import CourseCheckout from '../pages/CourseCheckout'
import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import AccountSettings from '../pages/AccountSettings'
import Notifications from '../pages/Notifications'
import IntroPage from '@/modules/auth/pages/Intro'
import { useAuth } from '@/contexts/AuthContext'
@@ -60,6 +60,7 @@ export const ClientRoutes = {
{ path: 'certificates', element: <MyCertificates /> },
{ path: 'achievements', element: <MyAchievements /> },
{ path: 'settings', element: <AccountSettings /> },
{ path: 'notifications', element: <Notifications /> },
{
path: 'plans', element: <Outlet />,
children: [
@@ -101,7 +102,6 @@ export const ClientRoutes = {
path: 'task/:taskId', element: <Outlet />,
children: [
{ index: true, element: <ViewTask /> },
{ path: 'requirement', element: <ViewRequirement />, handle: { showFooter: false } },
]
},
]