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:
@@ -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] ||
|
||||
|
||||
@@ -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.">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user