mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -178,9 +178,15 @@ const FileUpload = ({
|
||||
return ok;
|
||||
});
|
||||
if (rejected.length > 0) {
|
||||
setTimeout(() => toast.error(
|
||||
setTimeout(() => toast(
|
||||
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
|
||||
`Accepted types: ${allowed.join(", ")}.`
|
||||
`Accepted types: ${allowed.join(", ")}.`,
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}
|
||||
), 0);
|
||||
}
|
||||
}
|
||||
@@ -190,14 +196,26 @@ const FileUpload = ({
|
||||
if (maxFileCount) {
|
||||
const availableSlots = maxFileCount - prev.length;
|
||||
if (availableSlots <= 0) {
|
||||
setTimeout(() => toast.error(
|
||||
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
|
||||
setTimeout(() => toast(
|
||||
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`,
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}
|
||||
), 0);
|
||||
incoming = [];
|
||||
} else if (incoming.length > availableSlots) {
|
||||
setTimeout(() => toast.error(
|
||||
setTimeout(() => toast(
|
||||
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
|
||||
`(max ${maxFileCount}).`
|
||||
`(max ${maxFileCount}).`,
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}
|
||||
), 0);
|
||||
incoming = incoming.slice(0, availableSlots);
|
||||
}
|
||||
@@ -222,11 +240,14 @@ const FileUpload = ({
|
||||
}
|
||||
});
|
||||
if (duplicates.length > 0) {
|
||||
setTimeout(() => toast.error(
|
||||
duplicates.length === 1
|
||||
? `"${duplicates[0]}" is already attached.`
|
||||
: `${duplicates.length} files are already attached.`
|
||||
), 0);
|
||||
setTimeout(() => toast(duplicates.length === 1
|
||||
? `"${duplicates[0]}" is already attached.`
|
||||
: `${duplicates.length} files are already attached.`, {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}), 0);
|
||||
}
|
||||
const next = prev.concat(toAdd);
|
||||
toAdd.forEach((e) => simulateUpload(e.id));
|
||||
|
||||
@@ -40,7 +40,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
||||
courses.forEach((course) => {
|
||||
const prev = prevCompletedRef.current[course.id];
|
||||
if (course.completed && prev === false) {
|
||||
toast.success(`"${course.title}" has been automatically turned in!`);
|
||||
toast(`"${course.title}" has been automatically turned in!`, {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
prevCompletedRef.current[course.id] = !!course.completed;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
|
||||
import { ExternalLink, CheckCheck, RefreshCcw, Globe } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
|
||||
@@ -20,47 +20,41 @@ const normalizeUrl = (url) => {
|
||||
return `https://${url}`;
|
||||
};
|
||||
|
||||
// ── Meta fetcher ──────────────────────────────────────────────────────────────
|
||||
const fetchLinkMeta = async (url) => {
|
||||
const normalized = normalizeUrl(url);
|
||||
try {
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`);
|
||||
const json = await res.json();
|
||||
if (json.status === "success") {
|
||||
return {
|
||||
title: json.data.title ?? null,
|
||||
description: json.data.description ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
};
|
||||
}
|
||||
} catch { /* silently fail */ }
|
||||
return { title: null, description: null, image: null };
|
||||
};
|
||||
|
||||
const getDomain = (url) => {
|
||||
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
|
||||
catch { return url; }
|
||||
};
|
||||
|
||||
// ── Fallback banner — favicon over a gradient, no external preview fetch ──────
|
||||
const LinkImageFallback = ({ domain, favicon, className = "h-40" }) => {
|
||||
const [faviconFailed, setFaviconFailed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className} bg-gradient-to-br from-muted via-muted to-primary/10 flex flex-col items-center justify-center gap-2`}>
|
||||
{!faviconFailed && favicon ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt={domain}
|
||||
className="w-12 h-12 rounded-xl"
|
||||
onError={() => setFaviconFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="size-10 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── LinkCard ──────────────────────────────────────────────────────────────────
|
||||
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
|
||||
const [meta, setMeta] = useState({ title: null, description: null, image: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!link.url) return;
|
||||
fetchLinkMeta(link.url)
|
||||
.then((data) => setMeta(data))
|
||||
.finally(() => setLoading(false));
|
||||
}, [link.url]);
|
||||
|
||||
const domain = getDomain(link.url);
|
||||
const displayImage = meta.image ?? null;
|
||||
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
||||
const displayTitle = meta.title ?? link.label ?? domain;
|
||||
const displayDescription = meta.description ?? link.url;
|
||||
const domain = getDomain(link.url);
|
||||
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
||||
const displayTitle = link.label ?? domain;
|
||||
const displayDescription = link.url;
|
||||
|
||||
const handleTurnIn = async () => {
|
||||
await onTurnIn(link.requirement_id);
|
||||
@@ -75,39 +69,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
||||
return (
|
||||
<>
|
||||
<Card className="relative w-72 shrink-0 pt-0">
|
||||
{loading ? (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img
|
||||
src={displayFavicon}
|
||||
alt={domain}
|
||||
className="w-12 h-12 rounded-xl"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-t-lg" />
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-1">
|
||||
{loading
|
||||
? <span className="block h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
: displayTitle
|
||||
}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">
|
||||
{loading
|
||||
? <span className="block h-3 w-48 bg-muted animate-pulse rounded" />
|
||||
: displayDescription
|
||||
}
|
||||
</CardDescription>
|
||||
<CardTitle className="line-clamp-1">{displayTitle}</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{displayDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{visited ? (
|
||||
@@ -137,11 +102,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<div className="flex flex-col gap-3 w-fit">
|
||||
<Button asChild variant="link" className="text-blue-500">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
<ExternalLink /> {link.url}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -156,11 +120,6 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
|
||||
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
|
||||
</Button>
|
||||
@@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-40 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img src={displayFavicon} alt={domain} className="w-12 h-12 rounded-xl" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-lg" />
|
||||
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<div>
|
||||
<Button asChild variant="link" className="text-blue-500">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink /> {link.url}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
@@ -201,7 +155,7 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
||||
* called when user confirms "Turn In"
|
||||
*/
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const [unsubmittingId, setUnsubmittingId] = useState(null);
|
||||
|
||||
const handleTurnIn = async (requirementId) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Outlet, useMatches, useNavigate } from "react-router-dom"
|
||||
import { ThemeSwitcher } from "../components/ThemeSwitcher"
|
||||
import { useTheme } from "@/contexts/ThemeContext"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
import * as LucideIcons from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "sonner"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import api from "@/utils/api.util"
|
||||
import { ClientProvider } from "@/contexts/provider/ClientProvider"
|
||||
@@ -141,6 +142,7 @@ function getInitials(name = "") {
|
||||
function ClientNav() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
// Background fetches only — nav rendering never waits on these
|
||||
const { achievements, getAchievements } = useProfile()
|
||||
@@ -212,6 +214,7 @@ function ClientNav() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
setTheme('light')
|
||||
navigate("/login")
|
||||
}
|
||||
|
||||
@@ -295,7 +298,7 @@ function ClientNav() {
|
||||
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
||||
<Settings /> Account Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
{/* <DropdownMenuItem>
|
||||
<TableOfContents /> Documentation
|
||||
<DropdownMenuShortcut>
|
||||
<SquareArrowOutUpRight />
|
||||
@@ -306,7 +309,7 @@ function ClientNav() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
|
||||
<Gift /> Refer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Theme
|
||||
<DropdownMenuShortcut>
|
||||
@@ -343,14 +346,18 @@ const ClientLayout = () => {
|
||||
|
||||
return (
|
||||
<ClientProvider>
|
||||
<ClientNav />
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
{showFooter && (
|
||||
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||
© Philproperties, 2026
|
||||
</footer>
|
||||
)}
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ClientNav />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster position="bottom-right" richColors />
|
||||
{showFooter && (
|
||||
<footer className="bg-muted border-t w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||
© Philproperties, 2026
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</ClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { KeyRound, CreditCard, Mail, Megaphone, Info, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { KeyRound, CreditCard, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -63,11 +63,21 @@ function SecuritySection({ user, logout }) {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (form.new_password !== form.confirm) {
|
||||
toast.error("New passwords do not match.");
|
||||
toast("New passwords do not match.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (form.new_password.length < 8) {
|
||||
toast.error("New password must be at least 8 characters.");
|
||||
toast("New password must be at least 8 characters.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -76,13 +86,23 @@ function SecuritySection({ user, logout }) {
|
||||
current_password: form.current_password,
|
||||
new_password: form.new_password,
|
||||
});
|
||||
toast.success("Password changed. Logging you out…");
|
||||
toast("Password changed. Logging you out…", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not change password.");
|
||||
toast(err?.response?.data?.message ?? "Could not change password.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -229,111 +249,6 @@ function SubscriptionSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Newsletter ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NEWSLETTER_OPTIONS = [
|
||||
{
|
||||
key: "newsletter_course_updates",
|
||||
label: "Course updates",
|
||||
description: "Emails about new courses, lesson releases, and learning milestones.",
|
||||
},
|
||||
{
|
||||
key: "newsletter_announcements",
|
||||
label: "Announcements",
|
||||
description: "Platform news, promotions, and important updates from Philproperties.",
|
||||
},
|
||||
];
|
||||
|
||||
function NewsletterSection() {
|
||||
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (key, value) => {
|
||||
const result = await updateProfile({ [key]: value });
|
||||
if (result?.success) {
|
||||
toast.success(value ? "Preference saved." : "Preference saved.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{NEWSLETTER_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] ?? false}
|
||||
onCheckedChange={(v) => handleToggle(opt.key, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{i < NEWSLETTER_OPTIONS.length - 1 && <Separator className="mt-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Course Notices ───────────────────────────────────────────────────────────
|
||||
// One-time informational dialogs shown while studying (e.g. InfoDialog in
|
||||
// UnitList.jsx) — this list grows as more generic client notices are added.
|
||||
|
||||
const NOTICE_OPTIONS = [
|
||||
{
|
||||
key: "show_course_notices",
|
||||
label: "Course notices",
|
||||
description: "Informational pop-ups about course readiness, such as when an assessment hasn't been built yet.",
|
||||
},
|
||||
];
|
||||
|
||||
function CourseNoticesSection() {
|
||||
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (key, value) => {
|
||||
const result = await updateProfile({ [key]: value });
|
||||
if (result?.success) {
|
||||
toast.success("Preference saved.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{NOTICE_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 < NOTICE_OPTIONS.length - 1 && <Separator className="mt-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Advertisements ───────────────────────────────────────────────────────────
|
||||
|
||||
const AD_OPTIONS = [
|
||||
@@ -372,7 +287,13 @@ function AdvertisementsSection() {
|
||||
}
|
||||
const result = await updateProfile({ [key]: value });
|
||||
if (result?.success) {
|
||||
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
|
||||
toast("Preference saved.", {
|
||||
description: "Reload the page for this to take effect.",
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,14 +301,26 @@ function AdvertisementsSection() {
|
||||
setConfirmPopupOff(false);
|
||||
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
|
||||
if (result?.success) {
|
||||
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
|
||||
toast("Preference saved.", {
|
||||
description: "Reload the page for this to take effect.",
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideAllToggle = async (hide) => {
|
||||
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
|
||||
if (result?.success) {
|
||||
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
|
||||
toast("Preference saved.", {
|
||||
description: "Reload the page for this to take effect.",
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -454,69 +387,81 @@ function AdvertisementsSection() {
|
||||
}
|
||||
|
||||
// ─── Delete Account ───────────────────────────────────────────────────────────
|
||||
|
||||
function DeleteAccountSection({ logout }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete("/client/profile");
|
||||
toast.success("Account deleted. Goodbye!");
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not delete account.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">Delete account</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently remove your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your account and sign you out of all sessions.
|
||||
Your data cannot be recovered after deletion.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{loading ? "Deleting…" : "Yes, delete my account"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Disabled while still in development — keep implemented for when we're ready
|
||||
// to expose self-service account deletion.
|
||||
//
|
||||
// function DeleteAccountSection({ logout }) {
|
||||
// const navigate = useNavigate();
|
||||
// const [open, setOpen] = useState(false);
|
||||
// const [loading, setLoading] = useState(false);
|
||||
//
|
||||
// const handleDelete = async () => {
|
||||
// setLoading(true);
|
||||
// try {
|
||||
// await api.delete("/client/profile");
|
||||
// toast("Account deleted. Goodbye!", {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => {}
|
||||
// }
|
||||
// });
|
||||
// await logout();
|
||||
// navigate("/login");
|
||||
// } catch (err) {
|
||||
// toast(err?.response?.data?.message ?? "Could not delete account.", {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => {}
|
||||
// }
|
||||
// });
|
||||
// setLoading(false);
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// return (
|
||||
// <>
|
||||
// <div className="flex items-start justify-between gap-4">
|
||||
// <div className="space-y-1">
|
||||
// <p className="text-sm font-medium text-destructive">Delete account</p>
|
||||
// <p className="text-xs text-muted-foreground">
|
||||
// Permanently remove your account and all associated data. This action cannot be undone.
|
||||
// </p>
|
||||
// </div>
|
||||
// <Button
|
||||
// variant="destructive"
|
||||
// size="sm"
|
||||
// className="shrink-0"
|
||||
// onClick={() => setOpen(true)}
|
||||
// >
|
||||
// <Trash2 className="size-3.5 mr-1.5" />
|
||||
// Delete account
|
||||
// </Button>
|
||||
// </div>
|
||||
//
|
||||
// <AlertDialog open={open} onOpenChange={setOpen}>
|
||||
// <AlertDialogContent>
|
||||
// <AlertDialogHeader>
|
||||
// <AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||
// <AlertDialogDescription>
|
||||
// This will permanently delete your account and sign you out of all sessions.
|
||||
// Your data cannot be recovered after deletion.
|
||||
// </AlertDialogDescription>
|
||||
// </AlertDialogHeader>
|
||||
// <AlertDialogFooter>
|
||||
// <AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
// <AlertDialogAction
|
||||
// onClick={handleDelete}
|
||||
// disabled={loading}
|
||||
// className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
// >
|
||||
// {loading ? "Deleting…" : "Yes, delete my account"}
|
||||
// </AlertDialogAction>
|
||||
// </AlertDialogFooter>
|
||||
// </AlertDialogContent>
|
||||
// </AlertDialog>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -539,21 +484,13 @@ export default function AccountSettings() {
|
||||
<SubscriptionSection />
|
||||
</Section>
|
||||
|
||||
<Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
|
||||
<NewsletterSection />
|
||||
</Section>
|
||||
|
||||
<Section icon={Info} title="Course Notices" description="Control informational notices shown while studying.">
|
||||
<CourseNoticesSection />
|
||||
</Section>
|
||||
|
||||
<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.">
|
||||
{/* <Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
|
||||
<DeleteAccountSection logout={logout} />
|
||||
</Section>
|
||||
</Section> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,12 @@ const Checkout = () => {
|
||||
if (!wasCancelled) return;
|
||||
const orderId = searchParams.get("token");
|
||||
if (orderId) cancelOrder(orderId);
|
||||
toast.info("PayPal checkout was cancelled.");
|
||||
toast("PayPal checkout was cancelled.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
|
||||
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -153,9 +158,19 @@ const Checkout = () => {
|
||||
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
|
||||
if (result?.valid) {
|
||||
setPromoResult(result);
|
||||
toast.success("Promo code applied.");
|
||||
toast("Promo code applied.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toast.error(result?.reason ?? "Invalid promo code.");
|
||||
toast(result?.reason ?? "Invalid promo code.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +186,12 @@ const Checkout = () => {
|
||||
);
|
||||
if (!order) return;
|
||||
const approvalUrl = order.approval_url;
|
||||
if (!approvalUrl) { toast.error("Could not get PayPal approval URL."); return; }
|
||||
if (!approvalUrl) { toast("Could not get PayPal approval URL.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}); return; }
|
||||
window.location.href = approvalUrl;
|
||||
};
|
||||
|
||||
|
||||
@@ -68,7 +68,12 @@ export default function CourseCheckout() {
|
||||
if (!wasCancelled) return;
|
||||
const orderId = searchParams.get("token");
|
||||
if (orderId) cancelCourseOrder(orderId);
|
||||
toast.info("Payment was cancelled.");
|
||||
toast("Payment was cancelled.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
navigate(`/course/${courseId}/checkout`, { replace: true });
|
||||
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -76,7 +81,12 @@ export default function CourseCheckout() {
|
||||
if (!course?.product?.id) return;
|
||||
const order = await createCourseOrder(course.product.id);
|
||||
if (!order) return;
|
||||
if (!order.approval_url) { toast.error("Could not get PayPal approval URL."); return; }
|
||||
if (!order.approval_url) { toast("Could not get PayPal approval URL.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
}); return; }
|
||||
window.location.href = order.approval_url;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import api from "@/utils/api.util";
|
||||
import {
|
||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
||||
Hourglass,
|
||||
Hourglass, Check,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -44,7 +45,6 @@ function formatDuration(seconds = 0) {
|
||||
// ─── Spine / card helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const INTRO_HEIGHT = 50;
|
||||
const CX = 0;
|
||||
|
||||
const useVisibleNodes = (refs, count) => {
|
||||
const [visible, setVisible] = useState(new Set());
|
||||
@@ -388,66 +388,69 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
|
||||
const lastMid = mids.length ? mids[mids.length - 1] : 0;
|
||||
const svgH = lastMid + 40;
|
||||
const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0;
|
||||
const isIssued = !!certificate;
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="flex gap-5 px-4">
|
||||
{/* Spine */}
|
||||
<div className="relative flex-shrink-0 w-4" style={{ height: svgH }}>
|
||||
<div className="relative flex-shrink-0 w-7" style={{ height: svgH }}>
|
||||
{mids.length > 0 && (
|
||||
<svg
|
||||
className="absolute top-0 left-0 overflow-visible xs:hidden lg:block"
|
||||
width={12}
|
||||
height={svgH}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1={CX} y1={0} x2={CX} y2={svgH} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity={0.08} />
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const y1 = (mids[0] / 10) * i;
|
||||
const y2 = (mids[0] / 10) * (i + 1);
|
||||
const revealed = drawnTo >= y2;
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 overflow-visible text-border xs:hidden lg:block"
|
||||
width={2}
|
||||
height={svgH}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1={1} y1={0} x2={1} y2={svgH} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const y1 = (mids[0] / 10) * i;
|
||||
const y2 = (mids[0] / 10) * (i + 1);
|
||||
const revealed = drawnTo >= y2;
|
||||
return (
|
||||
<motion.line
|
||||
key={`intro-${i}`}
|
||||
x1={1} y1={y1} x2={1} y2={y2}
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
animate={{ opacity: revealed ? 1 : 0.3 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{mids[0] != null && drawnTo > mids[0] && (
|
||||
<motion.line
|
||||
key={`intro-${i}`}
|
||||
x1={CX} y1={y1} x2={CX} y2={y2}
|
||||
x1={1} y1={mids[0]} x2={1} y2={drawnTo}
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
animate={{ opacity: revealed ? (i + 1) / 10 : 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
initial={{ opacity: 0.3 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{mids[0] != null && drawnTo > mids[0] && (
|
||||
<motion.line
|
||||
x1={CX} y1={mids[0]} x2={CX} y2={drawnTo}
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</svg>
|
||||
{mids.map((mid, i) => {
|
||||
const visible = visibleNodes.has(i);
|
||||
// Last node (certificate) gets a gold fill
|
||||
const isCert = i === totalNodes - 1;
|
||||
return (
|
||||
<g key={`node-${i}`}>
|
||||
<motion.circle
|
||||
cx={CX} cy={mid} r={isCert ? 7 : 5}
|
||||
fill={isCert ? "#D4A017" : "currentColor"}
|
||||
stroke={isCert ? "#D4A017" : "currentColor"}
|
||||
strokeWidth="1.5"
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
|
||||
style={{ transformOrigin: `${CX}px ${mid}px` }}
|
||||
/>
|
||||
</g>
|
||||
<motion.div
|
||||
key={`node-${i}`}
|
||||
className={cn(
|
||||
"absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium xs:hidden lg:flex",
|
||||
isCert && isIssued ? "border-emerald-500 text-emerald-500" : "border-border text-muted-foreground"
|
||||
)}
|
||||
style={{ top: mid }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
|
||||
>
|
||||
{isCert ? <Check className="size-3.5" /> : i + 1}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="xs:-ml-10 lg:-ml-0 flex flex-col gap-6 flex-1 max-w-3xl" style={{ paddingTop: INTRO_HEIGHT }}>
|
||||
<div className="xs:-ml-12 lg:-ml-0 flex flex-col gap-6 flex-1 max-w-3xl" style={{ paddingTop: INTRO_HEIGHT }}>
|
||||
{nodes.map((node, ni) => {
|
||||
const delay = ni * 0.05;
|
||||
const nodeRef = (el) => (cardRefs.current[ni] = el);
|
||||
@@ -543,7 +546,7 @@ const CourseDetails = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [courseId]);
|
||||
|
||||
const bannerAd = advertisements["course_details.banner"] ?? null;
|
||||
const bannerAd = advertisements["course_details.banner"] ?? null;
|
||||
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
|
||||
|
||||
// Resolve badge image once course loads — issue a client stream token for
|
||||
@@ -563,7 +566,12 @@ const CourseDetails = () => {
|
||||
}, [course?.badge_asset_id, course?.badge_image_url]);
|
||||
|
||||
if (courseBlocked) {
|
||||
toast.error("You don't have access to this course. Upgrade your plan.");
|
||||
toast("You don't have access to this course. Upgrade your plan.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => { }
|
||||
}
|
||||
});
|
||||
navigate("/course", { replace: true });
|
||||
return null;
|
||||
}
|
||||
@@ -625,10 +633,20 @@ const CourseDetails = () => {
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="bg-muted dark:bg-accent/50">
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-8">
|
||||
<div><AppBreadcrumb items={items} /></div>
|
||||
<div className="flex lg:flex-row items-start justify-between w-full">
|
||||
<div className="bg-primary dark:bg-accent/50">
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
|
||||
<div>
|
||||
<AppBreadcrumb
|
||||
color={
|
||||
{
|
||||
link: { color: "text-white" },
|
||||
page: { color: "text-white" }
|
||||
}
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex lg:flex-row items-start justify-between w-full text-white">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
@@ -661,7 +679,7 @@ const CourseDetails = () => {
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-fit"
|
||||
className="w-fit bg-blue-500"
|
||||
onClick={() => navigate(`/course/${courseId}/unit`)}
|
||||
>
|
||||
{hasCompleted
|
||||
@@ -688,49 +706,58 @@ const CourseDetails = () => {
|
||||
{/* 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 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 className="flex flex-col xs:gap-4 lg:gap-12 flex-1 min-w-0">
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">About this course</div>
|
||||
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
|
||||
<p>{course?.description ?? ""}</p>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{course?.objectives?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Units — while content isn't ready, only Rewards is shown */}
|
||||
{course?.units?.length > 0 && (
|
||||
<>
|
||||
<div className="font-bold text-2xl">
|
||||
{contentNotReady ? "Rewards" : "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}
|
||||
contentNotReady={contentNotReady}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{course?.units?.length > 0 && (
|
||||
<>
|
||||
<div className="font-bold text-2xl">
|
||||
{contentNotReady ? "Rewards" : "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}
|
||||
contentNotReady={contentNotReady}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Advertisement Sidebar */}
|
||||
<aside className="hidden lg:block w-72 shrink-0 sticky top-24 h-fit">
|
||||
<aside className="hidden lg:block w-72 shrink-0 sticky top-36 h-fit">
|
||||
{adLoading["course_details.sidebar"] ? (
|
||||
<SidebarSkeleton />
|
||||
) : (
|
||||
|
||||
@@ -242,7 +242,7 @@ const CoursesList = () => {
|
||||
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
|
||||
<Input
|
||||
placeholder="Search courses..."
|
||||
className="w-full bg-card lg:max-w-64"
|
||||
className="w-full bg-card lg:max-w-64 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
@@ -272,31 +272,26 @@ const CoursesList = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{allCategories.length > 0 && (
|
||||
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Categories</SelectItem>
|
||||
{allCategories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product category chips */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === "All" ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === String(cat.id) ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advertisement Banner */}
|
||||
{adLoading["course_list.banner"] ? (
|
||||
<BannerSkeleton />
|
||||
@@ -315,7 +310,7 @@ const CoursesList = () => {
|
||||
<p className="text-md">No courses found</p>
|
||||
</div>
|
||||
) : (
|
||||
<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 mb-4">
|
||||
<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 mb-4 xs:px-4 lg:px-0">
|
||||
{paginated.map((course) => (
|
||||
<CourseCard
|
||||
key={course.course_id}
|
||||
|
||||
@@ -207,7 +207,11 @@ const Client = () => {
|
||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick, dismissPopupForever } = useClientAdvertisements();
|
||||
const {
|
||||
advertisements, getActiveAdvertisements,
|
||||
adLists, listLoading, getActiveAdvertisementList,
|
||||
handleAdCtaClick, dismissPopupForever,
|
||||
} = useClientAdvertisements();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
@@ -216,15 +220,19 @@ const Client = () => {
|
||||
|
||||
const userTier = myTier?.tier ?? "free";
|
||||
|
||||
const heroAd = advertisements["dashboard.hero"] ?? null;
|
||||
const heroAds = adLists["dashboard.hero"] ?? [];
|
||||
const popupAd = advertisements["dashboard.popup"] ?? null;
|
||||
|
||||
// Show welcome toast on first registration
|
||||
useEffect(() => {
|
||||
if (!navState?.justRegistered) return;
|
||||
toast.success('Welcome to Philproperties!', {
|
||||
toast('Welcome to Philproperties!', {
|
||||
description: 'You earned the Early Access badge. Check your notifications for details.',
|
||||
duration: 6000,
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
window.history.replaceState({}, '');
|
||||
}, []);
|
||||
@@ -238,11 +246,12 @@ const Client = () => {
|
||||
fetchGroups();
|
||||
}, [])
|
||||
|
||||
// ── Resolve active hero + popup ads once on mount ────────────────────────
|
||||
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
|
||||
useEffect(() => {
|
||||
getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => {
|
||||
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
|
||||
if (result["dashboard.popup"]) setPopupOpen(true);
|
||||
});
|
||||
getActiveAdvertisementList("dashboard.hero");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -268,13 +277,13 @@ const Client = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="my-20">
|
||||
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-8 px-16">
|
||||
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto xs:pt-2 lg:pt-8 lg:px-16 xs:px-4 sm:px-6">
|
||||
|
||||
{/* ── Hero Advertisement ── */}
|
||||
{adLoading["dashboard.hero"] ? (
|
||||
{/* ── Hero Advertisement Carousel ── */}
|
||||
{listLoading["dashboard.hero"] ? (
|
||||
<HeroSkeleton />
|
||||
) : (
|
||||
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
|
||||
<Hero ads={heroAds} onCtaClick={handleAdCtaClick} />
|
||||
)}
|
||||
|
||||
{/* ── My Groups ── */}
|
||||
|
||||
@@ -30,7 +30,12 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeI
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Could not download certificate.");
|
||||
toast("Could not download certificate.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
|
||||
@@ -141,8 +141,18 @@ export default function Notifications() {
|
||||
const ok = await clearAll();
|
||||
setClearing(false);
|
||||
setClearOpen(false);
|
||||
if (ok) toast.success("All notifications cleared.");
|
||||
else toast.error("Could not clear notifications.");
|
||||
if (ok) toast("All notifications cleared.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
else toast("Could not clear notifications.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1;
|
||||
@@ -176,7 +186,7 @@ export default function Notifications() {
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-destructive hover:text-destructive"
|
||||
@@ -185,7 +195,7 @@ export default function Notifications() {
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Clear all
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -385,12 +385,22 @@ export default function PlanList() {
|
||||
setRefundLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/client/tiers/checkout/refund");
|
||||
toast.success(data.message ?? "Refund processed. Your access has been revoked.");
|
||||
toast(data.message ?? "Refund processed. Your access has been revoked.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
setRefundPlan(null);
|
||||
resetMyTier();
|
||||
getMyTier();
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Refund failed. Please try again.");
|
||||
toast(err?.response?.data?.message ?? "Refund failed. Please try again.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setRefundLoading(false);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ function resolveTierBadge(myTier) {
|
||||
colorKey: category.color ?? "green",
|
||||
label: category.badge_label ?? category.name ?? tier,
|
||||
description: "",
|
||||
information: "",
|
||||
information: category.description ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,12 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badg
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Could not download certificate.");
|
||||
toast("Could not download certificate.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
|
||||
@@ -507,7 +507,12 @@ const UnitList = () => {
|
||||
useEffect(() => {
|
||||
if (!completedTasks.length) return;
|
||||
completedTasks.forEach((t) => {
|
||||
toast.success(`"${t.task_name}" automatically turned in!`);
|
||||
toast(`"${t.task_name}" automatically turned in!`, {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
clearCompletedTasks();
|
||||
}, [completedTasks]);
|
||||
|
||||
@@ -114,19 +114,7 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
||||
<h2 className="font-semibold text-base">Requirements</h2>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(({ key, label, icon, getValue }) => {
|
||||
const isProvided = reqTypes.includes(key);
|
||||
if (!isProvided) {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">Not provided</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{provided.map(({ key, label, icon, getValue }) => {
|
||||
const { done, total, binary } = getValue();
|
||||
const complete = total > 0 && done >= total;
|
||||
return (
|
||||
@@ -152,9 +140,14 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
||||
<div className="flex flex-col gap-2 pt-2 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Overall progress</span>
|
||||
<span className="text-sm">{completedCount} / {provided.length} done</span>
|
||||
{provided.length > 0 && completedCount >= provided.length && (
|
||||
<span className="text-sm">Completed</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={overallPercent} className="h-1.5" />
|
||||
<Progress
|
||||
value={overallPercent}
|
||||
className={`h-1.5 ${provided.length > 0 && completedCount >= provided.length ? "[&>div]:bg-green-500" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -322,7 +315,12 @@ const ViewTask = () => {
|
||||
}
|
||||
|
||||
if (!uploadedFiles.length) {
|
||||
toast.error('No files were uploaded successfully.');
|
||||
toast('No files were uploaded successfully.', {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -336,7 +334,12 @@ const ViewTask = () => {
|
||||
setNote('');
|
||||
setUploadState({ files: [], isUploading: false });
|
||||
} catch (err) {
|
||||
toast.error('Failed to submit. Please try again.');
|
||||
toast('Failed to submit. Please try again.', {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => {}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -410,7 +413,7 @@ const ViewTask = () => {
|
||||
<div className="grid lg:grid-cols-[1fr_350px] gap-6 items-start">
|
||||
|
||||
{/* ── Left ──────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-6 xs:order-2 lg:order-0 min-w-0 w-full max-w-full">
|
||||
<div className="flex flex-col gap-6 xs:order-1 lg:order-0 min-w-0 w-full max-w-full">
|
||||
|
||||
{/* Task header card */}
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
@@ -448,10 +451,6 @@ const ViewTask = () => {
|
||||
{/* Requirements section */}
|
||||
{!isResolving && requirements.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-lg">
|
||||
<h1>Requirements</h1>
|
||||
</div>
|
||||
|
||||
{/* visit_link */}
|
||||
{visitLinkReqs.length > 0 && (
|
||||
<VisitLink
|
||||
|
||||
Reference in New Issue
Block a user