mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,51 +1,95 @@
|
|||||||
// components/blocks/Hero.jsx
|
// components/blocks/Hero.jsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Generic hero advertisement block.
|
* Hero advertisement carousel.
|
||||||
* Two-column layout: badge/headline/description/CTAs on the left, image on the right.
|
* Each slide is a full-bleed background image with a bottom gradient overlay,
|
||||||
* Renders null when no ad is provided — callers should not fall back to placeholder copy.
|
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
|
||||||
|
* ads are given — callers should not fall back to placeholder copy.
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
* ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||||
*/
|
*/
|
||||||
export function Hero({ ad, onCtaClick }) {
|
export function Hero({ ads, onCtaClick }) {
|
||||||
if (!ad) return null;
|
const [api, setApi] = useState();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
setCount(api.scrollSnapList().length);
|
||||||
|
setCurrent(api.selectedScrollSnap() + 1);
|
||||||
|
|
||||||
|
api.on("select", () => {
|
||||||
|
setCurrent(api.selectedScrollSnap() + 1);
|
||||||
|
});
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
if (!ads?.length) return null;
|
||||||
|
|
||||||
|
const progressValue = count ? (current / count) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Carousel setApi={setApi} className="w-full">
|
||||||
|
<CarouselContent>
|
||||||
|
{ads.map((ad) => {
|
||||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
|
<CarouselItem key={ad.advertisement_id}>
|
||||||
<div className="flex flex-col gap-3">
|
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||||
|
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
|
||||||
|
{imageSrc ? (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={ad.headline || "Advertisement"}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
||||||
|
|
||||||
|
{/* Bottom-left content */}
|
||||||
|
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
|
||||||
{ad.badge_label && (
|
{ad.badge_label && (
|
||||||
<Badge variant="outline" className="pointer-events-none select-none">
|
<Badge variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
|
||||||
<Megaphone /> {ad.badge_label}
|
<Megaphone /> {ad.badge_label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{ad.headline && (
|
{ad.headline && (
|
||||||
<div className="font-bold text-4xl leading-12 pointer-events-none select-none">
|
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
|
||||||
{ad.headline}
|
{ad.headline}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ad.description && (
|
{ad.description && (
|
||||||
<p className="max-w-lg pointer-events-none select-none">
|
<p className="text-gray-200 xs:text-sm lg:text-md leading-relaxed pointer-events-none select-none line-clamp-2">
|
||||||
{ad.description}
|
{ad.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{ctas.length > 0 && (
|
{ctas.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
{ctas.map((cta, i) => (
|
{ctas.map((cta, i) => (
|
||||||
<Button
|
<Button
|
||||||
key={i}
|
key={i}
|
||||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||||
|
className={cta.variant !== "outline" ? "bg-[oklch(0.63_0.25_302)] text-white hover:bg-[oklch(0.63_0.25_302)]/85" : undefined}
|
||||||
onClick={() => onCtaClick?.(ad, cta)}
|
onClick={() => onCtaClick?.(ad, cta)}
|
||||||
>
|
>
|
||||||
{cta.label}
|
{cta.label}
|
||||||
@@ -54,13 +98,26 @@ export function Hero({ ad, onCtaClick }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden pointer-events-none select-none">
|
</CardContent>
|
||||||
{imageSrc ? (
|
</Card>
|
||||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
</CarouselItem>
|
||||||
) : (
|
);
|
||||||
<Megaphone className="size-8 text-muted-foreground" />
|
})}
|
||||||
)}
|
</CarouselContent>
|
||||||
|
|
||||||
|
{/* Navigation and Progress — only meaningful with more than one slide */}
|
||||||
|
{ads.length > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 px-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CarouselPrevious className="static translate-y-0 size-8 rounded-lg" />
|
||||||
|
<CarouselNext className="static translate-y-0 size-8 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 max-w-24 ml-4">
|
||||||
|
<Progress value={progressValue} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,8 +126,11 @@ export function Hero({ ad, onCtaClick }) {
|
|||||||
|
|
||||||
export function HeroSkeleton() {
|
export function HeroSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
|
<div className="w-full">
|
||||||
<div className="flex flex-col gap-3 w-full max-w-lg">
|
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||||
|
<CardContent className="relative xs:h-64 lg:h-96">
|
||||||
|
<Skeleton className="absolute inset-0 h-full w-full rounded-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 w-full max-w-lg">
|
||||||
<Skeleton className="h-6 w-32 rounded-full" />
|
<Skeleton className="h-6 w-32 rounded-full" />
|
||||||
<Skeleton className="h-10 w-3/4" />
|
<Skeleton className="h-10 w-3/4" />
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
@@ -80,7 +140,8 @@ export function HeroSkeleton() {
|
|||||||
<Skeleton className="h-9 w-28" />
|
<Skeleton className="h-9 w-28" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="rounded-lg w-xl aspect-video" />
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppBreadcrumb
|
* AppBreadcrumb
|
||||||
@@ -26,6 +27,11 @@ import {
|
|||||||
* ─────────────────────────────────────────────────────────────────────────────
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
* @param {Array} items Ordered list of breadcrumb item definitions
|
* @param {Array} items Ordered list of breadcrumb item definitions
|
||||||
|
* @param {Object} [color] Independent color overrides per element
|
||||||
|
* @param {Object} [color.link] Override for non-last (clickable) items
|
||||||
|
* @param {string} [color.link.color] className applied to BreadcrumbLink
|
||||||
|
* @param {Object} [color.page] Override for the last (current page) item
|
||||||
|
* @param {string} [color.page.color] className applied to BreadcrumbPage
|
||||||
*
|
*
|
||||||
* ─── Usage ───────────────────────────────────────────────────────────────────
|
* ─── Usage ───────────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
@@ -49,10 +55,18 @@ import {
|
|||||||
* ]}
|
* ]}
|
||||||
* />
|
* />
|
||||||
*
|
*
|
||||||
|
* // With per-element color overrides
|
||||||
|
* <AppBreadcrumb
|
||||||
|
* color={{ link: { color: "text-muted-foreground" }, page: { color: "text-[#000000]" } }}
|
||||||
|
* items={items}
|
||||||
|
* />
|
||||||
|
*
|
||||||
* ─────────────────────────────────────────────────────────────────────────────
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
const AppBreadcrumb = ({ items = [] }) => {
|
const AppBreadcrumb = ({ items = [], color = {} }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const linkColor = color.link?.color ?? "";
|
||||||
|
const pageColor = color.page?.color ?? "";
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
@@ -66,14 +80,14 @@ const AppBreadcrumb = ({ items = [] }) => {
|
|||||||
<span key={index} className="flex items-center gap-1.5">
|
<span key={index} className="flex items-center gap-1.5">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage className="flex items-center gap-2 max-w-[300px] truncate">
|
<BreadcrumbPage className={cn("flex items-center gap-2 max-w-[300px] truncate", pageColor)}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 select-none cursor-pointer max-w-[300px]"
|
className={cn("flex items-center gap-2 select-none cursor-pointer max-w-[300px]", linkColor)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (item.onClick) {
|
if (item.onClick) {
|
||||||
item.onClick(e, navigate);
|
item.onClick(e, navigate);
|
||||||
|
|||||||
@@ -30,17 +30,11 @@ export default function DashboardGrid({ sections = [] }) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={key}
|
key={key}
|
||||||
onClick={(e) => handleNavigate(e, link)}
|
onClick={(e) => handleNavigate(e, link)}
|
||||||
className="group bg-card border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer"
|
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||||
whileHover={{
|
whileHover={{ y: -6, scale: 1.02 }}
|
||||||
y: -6,
|
|
||||||
scale: 1.02,
|
|
||||||
backgroundColor: "var(--primary)",
|
|
||||||
color: "var(--motion-card-hover)"
|
|
||||||
}}
|
|
||||||
transition={{
|
transition={{
|
||||||
y: { type: "spring", stiffness: 300, damping: 20 },
|
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
scale: { type: "spring", stiffness: 300, damping: 20 },
|
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
backgroundColor: { duration: 0.2, ease: "easeOut" },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -50,7 +44,10 @@ export default function DashboardGrid({ sections = [] }) {
|
|||||||
>
|
>
|
||||||
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div className="p-4 font-medium mt-auto" whileHover={{ y: -2 }}>
|
<motion.div
|
||||||
|
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ function SignOutOverlay({ open }) {
|
|||||||
// ─── Main UserMenu ────────────────────────────────────────────────────────────
|
// ─── Main UserMenu ────────────────────────────────────────────────────────────
|
||||||
export default function UserMenu() {
|
export default function UserMenu() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const { setTheme } = useTheme()
|
||||||
const { avatarUrl } = useProfile()
|
const { avatarUrl } = useProfile()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -207,6 +208,7 @@ export default function UserMenu() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setSigningOut(true)
|
setSigningOut(true)
|
||||||
await logout()
|
await logout()
|
||||||
|
setTheme('light')
|
||||||
navigate('/login', { replace: true })
|
navigate('/login', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function Progress({
|
|||||||
{...props}>
|
{...props}>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
data-slot="progress-indicator"
|
data-slot="progress-indicator"
|
||||||
className={cn("size-full flex-1 transition-all", value >= 100 ? "bg-green-500" : "bg-primary")}
|
className="size-full flex-1 bg-primary transition-all"
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function AdminAchievementsProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { return await fn(); }
|
try { return await fn(); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Something went wrong.");
|
toast(err?.response?.data?.message ?? "Something went wrong.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -41,7 +46,12 @@ export function AdminAchievementsProvider({ children }) {
|
|||||||
const createAchievement = useCallback((payload) =>
|
const createAchievement = useCallback((payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.post("/admin/achievements", payload);
|
const { data } = await api.post("/admin/achievements", payload);
|
||||||
toast.success("Achievement created.");
|
toast("Achievement created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
@@ -52,7 +62,12 @@ export function AdminAchievementsProvider({ children }) {
|
|||||||
prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a))
|
prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a))
|
||||||
);
|
);
|
||||||
if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data);
|
if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data);
|
||||||
toast.success("Achievement updated.");
|
toast("Achievement updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request, achievement]);
|
}), [request, achievement]);
|
||||||
|
|
||||||
@@ -60,7 +75,12 @@ export function AdminAchievementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`/admin/achievements/${id}`);
|
await api.delete(`/admin/achievements/${id}`);
|
||||||
setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id)));
|
setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id)));
|
||||||
toast.success("Achievement deleted.");
|
toast("Achievement deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -100,7 +105,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
const advertisement = res.data?.data?.data ?? null;
|
const advertisement = res.data?.data?.data ?? null;
|
||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => [advertisement, ...prev]);
|
setAdvertisements((prev) => [advertisement, ...prev]);
|
||||||
toast.success("Advertisement created successfully.");
|
toast("Advertisement created successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -116,7 +126,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
|
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
|
||||||
setSelectedAdvertisement(advertisement);
|
setSelectedAdvertisement(advertisement);
|
||||||
toast.success("Advertisement updated successfully.");
|
toast("Advertisement updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -132,7 +147,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
|
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
|
||||||
toast.success("Advertisement archived.");
|
toast("Advertisement archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -146,7 +166,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast.success(`${ids.length} advertisement(s) archived.`);
|
toast(`${ids.length} advertisement(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -160,7 +185,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
const advertisement = res.data?.data?.data ?? null;
|
const advertisement = res.data?.data?.data ?? null;
|
||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
toast.success("Advertisement restored.");
|
toast("Advertisement restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -173,7 +203,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
|
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast.success(`${ids.length} advertisement(s) restored.`);
|
toast(`${ids.length} advertisement(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -185,7 +220,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`);
|
const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`);
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
toast.success("Advertisement permanently deleted.");
|
toast("Advertisement permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -197,7 +237,12 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } });
|
const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } });
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast.success(`${ids.length} advertisement(s) permanently deleted.`);
|
toast(`${ids.length} advertisement(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ export function AssetsProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -201,7 +206,12 @@ export function AssetsProvider({ children }) {
|
|||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => [asset, ...prev]);
|
setAssets((prev) => [asset, ...prev]);
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success("Asset uploaded successfully.");
|
toast("Asset uploaded successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -227,7 +237,12 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success("Asset updated successfully.");
|
toast("Asset updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -244,7 +259,12 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success("Asset archived.");
|
toast("Asset archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -259,7 +279,12 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success(`${ids.length} asset(s) archived.`);
|
toast(`${ids.length} asset(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -274,7 +299,12 @@ export function AssetsProvider({ children }) {
|
|||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success("Asset restored.");
|
toast("Asset restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -288,7 +318,12 @@ export function AssetsProvider({ children }) {
|
|||||||
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success(`${ids.length} asset(s) restored.`);
|
toast(`${ids.length} asset(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -302,7 +337,12 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success("Asset permanently deleted.");
|
toast("Asset permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -317,7 +357,12 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast.success(`${ids.length} asset(s) permanently deleted.`);
|
toast(`${ids.length} asset(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export function AdminCategoriesProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { return await fn(); }
|
try { return await fn(); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Something went wrong.");
|
toast(err?.response?.data?.message ?? "Something went wrong.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -32,28 +37,48 @@ export function AdminCategoriesProvider({ children }) {
|
|||||||
|
|
||||||
const createCategory = useCallback((payload) => wrap(async () => {
|
const createCategory = useCallback((payload) => wrap(async () => {
|
||||||
const { data } = await api.post("/admin/categories", payload);
|
const { data } = await api.post("/admin/categories", payload);
|
||||||
toast.success("Category created.");
|
toast("Category created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [wrap]);
|
}), [wrap]);
|
||||||
|
|
||||||
const updateCategory = useCallback((id, payload) => wrap(async () => {
|
const updateCategory = useCallback((id, payload) => wrap(async () => {
|
||||||
const { data } = await api.put(`/admin/categories/${id}`, payload);
|
const { data } = await api.put(`/admin/categories/${id}`, payload);
|
||||||
setCategory(data.data ?? null);
|
setCategory(data.data ?? null);
|
||||||
toast.success("Category updated.");
|
toast("Category updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [wrap]);
|
}), [wrap]);
|
||||||
|
|
||||||
const archiveCategory = useCallback((id) => wrap(async () => {
|
const archiveCategory = useCallback((id) => wrap(async () => {
|
||||||
await api.delete(`/admin/categories/${id}`);
|
await api.delete(`/admin/categories/${id}`);
|
||||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||||
toast.success("Category archived.");
|
toast("Category archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}), [wrap]);
|
}), [wrap]);
|
||||||
|
|
||||||
const restoreCategory = useCallback((id) => wrap(async () => {
|
const restoreCategory = useCallback((id) => wrap(async () => {
|
||||||
await api.post(`/admin/categories/${id}/restore`);
|
await api.post(`/admin/categories/${id}/restore`);
|
||||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||||
toast.success("Category restored.");
|
toast("Category restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}), [wrap]);
|
}), [wrap]);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export function AdminCourseReadingProgressProvider({ children }) {
|
|||||||
setProgressList(data.data ?? []);
|
setProgressList(data.data ?? []);
|
||||||
setDetailCache({});
|
setDetailCache({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not load reading progress.');
|
toast(err?.response?.data?.message ?? 'Could not load reading progress.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setListLoading(false);
|
setListLoading(false);
|
||||||
}
|
}
|
||||||
@@ -60,7 +65,12 @@ export function AdminCourseReadingProgressProvider({ children }) {
|
|||||||
setDetailCache((prev) => ({ ...prev, [userId]: breakdown }));
|
setDetailCache((prev) => ({ ...prev, [userId]: breakdown }));
|
||||||
return breakdown;
|
return breakdown;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not load user progress.');
|
toast(err?.response?.data?.message ?? 'Could not load user progress.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
|
|||||||
@@ -52,8 +52,18 @@ export function CoursesProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
if (err.status === 404 && message) toast.warning(message);
|
if (err.status === 404 && message) toast(message, {
|
||||||
else toast.error(message);
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
else toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -111,7 +121,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const course = data?.data?.data ?? null;
|
const course = data?.data?.data ?? null;
|
||||||
if (course) {
|
if (course) {
|
||||||
setCourses((prev) => [course, ...prev]);
|
setCourses((prev) => [course, ...prev]);
|
||||||
toast.success("Course created successfully.");
|
toast("Course created successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -126,7 +141,12 @@ export function CoursesProvider({ children }) {
|
|||||||
if (course) {
|
if (course) {
|
||||||
setCourses((prev) => prev.map((c) => (c.course_id === courseId ? course : c)));
|
setCourses((prev) => prev.map((c) => (c.course_id === courseId ? course : c)));
|
||||||
setCourse(course);
|
setCourse(course);
|
||||||
toast.success("Course updated successfully.");
|
toast("Course updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -139,7 +159,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}`);
|
const { data } = await api.delete(`${BASE}/${courseId}`);
|
||||||
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
||||||
setCourse((prev) => (prev?.course_id === courseId ? null : prev));
|
setCourse((prev) => (prev?.course_id === courseId ? null : prev));
|
||||||
toast.success("Course archived.");
|
toast("Course archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -150,7 +175,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/bulk`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/bulk`, { data: { ids } });
|
||||||
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
||||||
toast.success("Courses archived.");
|
toast("Courses archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -181,7 +211,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
||||||
toast.success("Course restored.");
|
toast("Course restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -193,7 +228,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/restore/bulk`, { ids });
|
const { data } = await api.patch(`${BASE}/restore/bulk`, { ids });
|
||||||
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
||||||
toast.success("Courses restored.");
|
toast("Courses restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -205,7 +245,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/permanent`);
|
const { data } = await api.delete(`${BASE}/${courseId}/permanent`);
|
||||||
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
setCourses((prev) => prev.filter((c) => c.course_id !== courseId));
|
||||||
setCourse((prev) => (prev?.course_id === courseId ? null : prev));
|
setCourse((prev) => (prev?.course_id === courseId ? null : prev));
|
||||||
toast.success("Course permanently deleted.");
|
toast("Course permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -216,7 +261,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/bulk/permanent`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/bulk/permanent`, { data: { ids } });
|
||||||
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id)));
|
||||||
toast.success("Courses permanently deleted.");
|
toast("Courses permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -255,7 +305,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.put(`${BASE}/${courseId}/prerequisites`, { prerequisites });
|
const { data } = await api.put(`${BASE}/${courseId}/prerequisites`, { prerequisites });
|
||||||
setPrerequisites(prerequisites);
|
setPrerequisites(prerequisites);
|
||||||
toast.success("Prerequisites updated.");
|
toast("Prerequisites updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -305,7 +360,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const unit = data?.data?.data ?? null;
|
const unit = data?.data?.data ?? null;
|
||||||
if (unit) {
|
if (unit) {
|
||||||
setUnits((prev) => [...prev, unit]);
|
setUnits((prev) => [...prev, unit]);
|
||||||
toast.success("Unit created successfully.");
|
toast("Unit created successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -320,7 +380,12 @@ export function CoursesProvider({ children }) {
|
|||||||
if (unit) {
|
if (unit) {
|
||||||
setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
|
setUnits((prev) => prev.map((u) => (u.unit_id === unitId ? unit : u)));
|
||||||
setUnit(unit);
|
setUnit(unit);
|
||||||
toast.success("Unit updated successfully.");
|
toast("Unit updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -333,7 +398,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`);
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}`);
|
||||||
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
||||||
toast.success("Unit archived.");
|
toast("Unit archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -344,7 +414,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/bulk`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/bulk`, { data: { ids } });
|
||||||
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
||||||
toast.success("Units archived.");
|
toast("Units archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -376,7 +451,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
toast.success("Unit restored.");
|
toast("Unit restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -388,7 +468,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/units/restore/bulk`, { ids } );
|
const { data } = await api.patch(`${BASE}/${courseId}/units/restore/bulk`, { ids } );
|
||||||
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
||||||
toast.success("Units restored.");
|
toast("Units restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -400,7 +485,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/permanent`);
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/permanent`);
|
||||||
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
setUnits((prev) => prev.filter((u) => u.unit_id !== unitId));
|
||||||
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
setUnit((prev) => (prev?.unit_id === unitId ? null : prev));
|
||||||
toast.success("Unit permanently deleted.");
|
toast("Unit permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -411,7 +501,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/bulk/permanent`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/bulk/permanent`, { data: { ids } });
|
||||||
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id)));
|
||||||
toast.success("Units permanently deleted.");
|
toast("Units permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -450,7 +545,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
toast.success("Quiz created.");
|
toast("Quiz created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -464,7 +564,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
toast.success("Quiz updated.");
|
toast("Quiz updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -477,7 +582,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}`, { data: { deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}`, { data: { deletedBy } });
|
||||||
setQuiz(null);
|
setQuiz(null);
|
||||||
setQuestions([]);
|
setQuestions([]);
|
||||||
toast.success("Quiz archived.");
|
toast("Quiz archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -503,7 +613,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuiz(result);
|
setQuiz(result);
|
||||||
toast.success("Quiz restored.");
|
toast("Quiz restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -532,7 +647,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => [...prev, result]);
|
setQuestions((prev) => [...prev, result]);
|
||||||
toast.success("Question added.");
|
toast("Question added.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -546,7 +666,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
||||||
toast.success("Question updated.");
|
toast("Question updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -559,7 +684,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy });
|
const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy });
|
||||||
const result = data?.data?.data ?? [];
|
const result = data?.data?.data ?? [];
|
||||||
setQuestions(result);
|
setQuestions(result);
|
||||||
toast.success("Quiz saved.");
|
toast("Quiz saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -570,7 +700,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}`, { data: { deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}`, { data: { deletedBy } });
|
||||||
setQuestions((prev) => prev.filter((q) => q.question_id !== questionId));
|
setQuestions((prev) => prev.filter((q) => q.question_id !== questionId));
|
||||||
toast.success("Question archived.");
|
toast("Question archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -581,7 +716,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk`, { data: { ids, deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk`, { data: { ids, deletedBy } });
|
||||||
setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id)));
|
setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id)));
|
||||||
toast.success("Questions archived.");
|
toast("Questions archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -602,7 +742,12 @@ export function CoursesProvider({ children }) {
|
|||||||
(courseId, unitId, quizId, questionId, restoredBy) =>
|
(courseId, unitId, quizId, questionId, restoredBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}/restore`, { restoredBy });
|
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/${questionId}/restore`, { restoredBy });
|
||||||
toast.success("Question restored.");
|
toast("Question restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -612,7 +757,12 @@ export function CoursesProvider({ children }) {
|
|||||||
(courseId, unitId, quizId, ids, restoredBy) =>
|
(courseId, unitId, quizId, ids, restoredBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/restore/bulk`, { ids, restoredBy });
|
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/restore/bulk`, { ids, restoredBy });
|
||||||
toast.success("Questions restored.");
|
toast("Questions restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -647,7 +797,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const lesson = data?.data?.data ?? null;
|
const lesson = data?.data?.data ?? null;
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
setLessons((prev) => [...prev, lesson]);
|
setLessons((prev) => [...prev, lesson]);
|
||||||
toast.success("Lesson created successfully.");
|
toast("Lesson created successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -662,7 +817,12 @@ export function CoursesProvider({ children }) {
|
|||||||
if (lesson) {
|
if (lesson) {
|
||||||
setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
|
setLessons((prev) => prev.map((l) => (l.lesson_id === lessonId ? lesson : l)));
|
||||||
setLesson(lesson);
|
setLesson(lesson);
|
||||||
toast.success("Lesson updated successfully.");
|
toast("Lesson updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -675,7 +835,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`);
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}`);
|
||||||
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
||||||
toast.success("Lesson archived.");
|
toast("Lesson archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -686,7 +851,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk`, { data: { ids } });
|
||||||
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast.success("Lessons archived.");
|
toast("Lessons archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -718,7 +888,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
toast.success("Lesson restored.");
|
toast("Lesson restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -730,7 +905,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/lessons/restore/bulk`, { ids });
|
const { data } = await api.patch(`${BASE}/${courseId}/units/${unitId}/lessons/restore/bulk`, { ids });
|
||||||
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast.success("Lessons restored.");
|
toast("Lessons restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -742,7 +922,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/permanent`);
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/permanent`);
|
||||||
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId));
|
||||||
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev));
|
||||||
toast.success("Lesson permanently deleted.");
|
toast("Lesson permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -753,7 +938,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk/permanent`, { data: { ids } });
|
const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk/permanent`, { data: { ids } });
|
||||||
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id)));
|
||||||
toast.success("Lessons permanently deleted.");
|
toast("Lessons permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -781,7 +971,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const page = data?.data?.data ?? null;
|
const page = data?.data?.data ?? null;
|
||||||
if (page) {
|
if (page) {
|
||||||
setLessonPage(page);
|
setLessonPage(page);
|
||||||
toast.success("Lesson page saved.");
|
toast("Lesson page saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -811,7 +1006,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setAssessment(result);
|
setAssessment(result);
|
||||||
toast.success("Assessment created.");
|
toast("Assessment created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -825,7 +1025,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setAssessment(result);
|
setAssessment(result);
|
||||||
toast.success("Assessment updated.");
|
toast("Assessment updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -838,7 +1043,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}`, { data: { deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}`, { data: { deletedBy } });
|
||||||
setAssessment(null);
|
setAssessment(null);
|
||||||
setQuestions([]);
|
setQuestions([]);
|
||||||
toast.success("Assessment archived.");
|
toast("Assessment archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -864,7 +1074,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setAssessment(result);
|
setAssessment(result);
|
||||||
toast.success("Assessment restored.");
|
toast("Assessment restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -893,7 +1108,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => [...prev, result]);
|
setQuestions((prev) => [...prev, result]);
|
||||||
toast.success("Question added.");
|
toast("Question added.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -907,7 +1127,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const result = data?.data?.data ?? null;
|
const result = data?.data?.data ?? null;
|
||||||
if (result) {
|
if (result) {
|
||||||
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
setQuestions((prev) => prev.map((q) => (q.question_id === questionId ? result : q)));
|
||||||
toast.success("Question updated.");
|
toast("Question updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -920,7 +1145,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy });
|
const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy });
|
||||||
const result = data?.data?.data ?? [];
|
const result = data?.data?.data ?? [];
|
||||||
setQuestions(result);
|
setQuestions(result);
|
||||||
toast.success("Assessment saved.");
|
toast("Assessment saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -931,7 +1161,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}`, { data: { deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}`, { data: { deletedBy } });
|
||||||
setQuestions((prev) => prev.filter((q) => q.question_id !== questionId));
|
setQuestions((prev) => prev.filter((q) => q.question_id !== questionId));
|
||||||
toast.success("Question archived.");
|
toast("Question archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -942,7 +1177,12 @@ export function CoursesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk`, { data: { ids, deletedBy } });
|
const { data } = await api.delete(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk`, { data: { ids, deletedBy } });
|
||||||
setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id)));
|
setQuestions((prev) => prev.filter((q) => !ids.includes(q.question_id)));
|
||||||
toast.success("Questions archived.");
|
toast("Questions archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -963,7 +1203,12 @@ export function CoursesProvider({ children }) {
|
|||||||
(courseId, assessmentId, questionId, restoredBy) =>
|
(courseId, assessmentId, questionId, restoredBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}/restore`, { restoredBy });
|
const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/${questionId}/restore`, { restoredBy });
|
||||||
toast.success("Question restored.");
|
toast("Question restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -973,7 +1218,12 @@ export function CoursesProvider({ children }) {
|
|||||||
(courseId, assessmentId, ids, restoredBy) =>
|
(courseId, assessmentId, ids, restoredBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/restore/bulk`, { ids, restoredBy });
|
const { data } = await api.patch(`${BASE}/${courseId}/assessment/${assessmentId}/questions/restore/bulk`, { ids, restoredBy });
|
||||||
toast.success("Questions restored.");
|
toast("Questions restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
@@ -1030,7 +1280,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const saveCourseProduct = useCallback(
|
const saveCourseProduct = useCallback(
|
||||||
(courseId, payload) => request(async () => {
|
(courseId, payload) => request(async () => {
|
||||||
const { data } = await api.put(`/admin/products/courses/${courseId}/product`, payload);
|
const { data } = await api.put(`/admin/products/courses/${courseId}/product`, payload);
|
||||||
toast.success("Product listing saved.");
|
toast("Product listing saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
}), [request],
|
}), [request],
|
||||||
);
|
);
|
||||||
@@ -1038,7 +1293,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const removeCourseProduct = useCallback(
|
const removeCourseProduct = useCallback(
|
||||||
(courseId) => request(async () => {
|
(courseId) => request(async () => {
|
||||||
await api.delete(`/admin/products/courses/${courseId}/product`);
|
await api.delete(`/admin/products/courses/${courseId}/product`);
|
||||||
toast.success("Product listing removed.");
|
toast("Product listing removed.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}), [request],
|
}), [request],
|
||||||
);
|
);
|
||||||
@@ -1053,7 +1313,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const syncCourseCategories = useCallback(
|
const syncCourseCategories = useCallback(
|
||||||
(courseId, categoryIds) => request(async () => {
|
(courseId, categoryIds) => request(async () => {
|
||||||
const { data } = await api.post(`/admin/products/courses/${courseId}/categories`, { category_ids: categoryIds });
|
const { data } = await api.post(`/admin/products/courses/${courseId}/categories`, { category_ids: categoryIds });
|
||||||
toast.success("Categories updated.");
|
toast("Categories updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data ?? [];
|
return data.data ?? [];
|
||||||
}), [request],
|
}), [request],
|
||||||
);
|
);
|
||||||
@@ -1068,7 +1333,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const syncInstructors = useCallback(
|
const syncInstructors = useCallback(
|
||||||
(courseId, instructors) => request(async () => {
|
(courseId, instructors) => request(async () => {
|
||||||
await api.put(`${BASE}/${courseId}/instructors`, { instructors });
|
await api.put(`${BASE}/${courseId}/instructors`, { instructors });
|
||||||
toast.success("Instructors updated.");
|
toast("Instructors updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}), [request],
|
}), [request],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1082,7 +1352,12 @@ export function CoursesProvider({ children }) {
|
|||||||
const syncCourseAchievements = useCallback(
|
const syncCourseAchievements = useCallback(
|
||||||
(courseId, achievement_keys) => request(async () => {
|
(courseId, achievement_keys) => request(async () => {
|
||||||
await api.put(`${BASE}/${courseId}/achievements`, { achievement_keys });
|
await api.put(`${BASE}/${courseId}/achievements`, { achievement_keys });
|
||||||
toast.success("Rewards updated.");
|
toast("Rewards updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}), [request],
|
}), [request],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ export function AdminDashboardProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -100,7 +105,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const broadcast = res.data?.data?.data ?? null;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => [broadcast, ...prev]);
|
setBroadcasts((prev) => [broadcast, ...prev]);
|
||||||
toast.success("Notification broadcast created.");
|
toast("Notification broadcast created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -116,7 +126,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
setSelectedBroadcast(broadcast);
|
setSelectedBroadcast(broadcast);
|
||||||
toast.success("Notification broadcast updated.");
|
toast("Notification broadcast updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -132,7 +147,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
setSelectedBroadcast(broadcast);
|
setSelectedBroadcast(broadcast);
|
||||||
toast.success("Notification broadcast sent.");
|
toast("Notification broadcast sent.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -148,7 +168,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
|
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
|
||||||
toast.success("Notification broadcast archived.");
|
toast("Notification broadcast archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -162,7 +187,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast.success(`${ids.length} notification broadcast(s) archived.`);
|
toast(`${ids.length} notification broadcast(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -176,7 +206,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const broadcast = res.data?.data?.data ?? null;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
toast.success("Notification broadcast restored.");
|
toast("Notification broadcast restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -189,7 +224,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch("/admin/notification-broadcasts/bulk-restore", { ids });
|
const res = await api.patch("/admin/notification-broadcasts/bulk-restore", { ids });
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast.success(`${ids.length} notification broadcast(s) restored.`);
|
toast(`${ids.length} notification broadcast(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -201,7 +241,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}/permanent`);
|
const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}/permanent`);
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
toast.success("Notification broadcast permanently deleted.");
|
toast("Notification broadcast permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -213,7 +258,12 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/notification-broadcasts/bulk/permanent", { data: { ids } });
|
const res = await api.delete("/admin/notification-broadcasts/bulk/permanent", { data: { ids } });
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast.success(`${ids.length} notification broadcast(s) permanently deleted.`);
|
toast(`${ids.length} notification broadcast(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function AdminNotificationTemplateProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { return await fn(); }
|
try { return await fn(); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Something went wrong.");
|
toast(err?.response?.data?.message ?? "Something went wrong.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -45,7 +50,12 @@ export function AdminNotificationTemplateProvider({ children }) {
|
|||||||
prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t))
|
prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t))
|
||||||
);
|
);
|
||||||
if (template && String(template.notification_template_id) === String(id)) setTemplate(data.data);
|
if (template && String(template.notification_template_id) === String(id)) setTemplate(data.data);
|
||||||
toast.success("Notification template updated.");
|
toast("Notification template updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request, template]);
|
}), [request, template]);
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -70,7 +75,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setCompletionLoading(false);
|
setCompletionLoading(false);
|
||||||
@@ -133,7 +143,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(payload) =>
|
(payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(BASE, payload);
|
const res = await api.post(BASE, payload);
|
||||||
toast.success('Task list created.');
|
toast('Task list created.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data?.data ?? null;
|
return res.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -143,7 +158,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, payload) =>
|
(taskListId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`${BASE}/${taskListId}`, payload);
|
const res = await api.patch(`${BASE}/${taskListId}`, payload);
|
||||||
toast.success('Task list updated.');
|
toast('Task list updated.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data?.data ?? null;
|
return res.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -153,7 +173,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId) =>
|
(taskListId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`${BASE}/${taskListId}`);
|
await api.delete(`${BASE}/${taskListId}`);
|
||||||
toast.success('Task list archived.');
|
toast('Task list archived.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -163,7 +188,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId) =>
|
(taskListId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.patch(`${BASE}/${taskListId}/restore`);
|
await api.patch(`${BASE}/${taskListId}/restore`);
|
||||||
toast.success('Task list restored.');
|
toast('Task list restored.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -173,7 +203,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(ids) =>
|
(ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/bulk-archive`, { ids });
|
await api.post(`${BASE}/bulk-archive`, { ids });
|
||||||
toast.success(`${ids.length} task list(s) archived.`);
|
toast(`${ids.length} task list(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -183,7 +218,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(ids) =>
|
(ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/bulk-restore`, { ids });
|
await api.post(`${BASE}/bulk-restore`, { ids });
|
||||||
toast.success(`${ids.length} task list(s) restored.`);
|
toast(`${ids.length} task list(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -193,7 +233,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId) =>
|
(taskListId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`${BASE}/${taskListId}/permanent`);
|
await api.delete(`${BASE}/${taskListId}/permanent`);
|
||||||
toast.success('Task list permanently deleted.');
|
toast('Task list permanently deleted.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -203,7 +248,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(ids) =>
|
(ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/bulk-delete`, { ids });
|
await api.post(`${BASE}/bulk-delete`, { ids });
|
||||||
toast.success(`${ids.length} task list(s) permanently deleted.`);
|
toast(`${ids.length} task list(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -254,9 +304,19 @@ export function AdminTaskProvider({ children }) {
|
|||||||
});
|
});
|
||||||
const result = res.data?.data ?? {};
|
const result = res.data?.data ?? {};
|
||||||
if (result.assigned_ids?.length) {
|
if (result.assigned_ids?.length) {
|
||||||
toast.success(`${result.assigned_ids.length} group(s) assigned.`);
|
toast(`${result.assigned_ids.length} group(s) assigned.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.info('All selected groups were already assigned.');
|
toast('All selected groups were already assigned.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -270,7 +330,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
group_ids: groupIds,
|
group_ids: groupIds,
|
||||||
});
|
});
|
||||||
const result = res.data?.data ?? {};
|
const result = res.data?.data ?? {};
|
||||||
toast.success(`${result.unassigned_ids?.length ?? 0} group(s) unassigned.`);
|
toast(`${result.unassigned_ids?.length ?? 0} group(s) unassigned.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -331,7 +396,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, payload) =>
|
(taskListId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/${taskListId}/tasks`, payload);
|
const res = await api.post(`${BASE}/${taskListId}/tasks`, payload);
|
||||||
toast.success('Task created.');
|
toast('Task created.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data?.data?.data ?? null;
|
return res.data?.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -341,7 +411,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, taskId, payload) =>
|
(taskListId, taskId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload);
|
const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload);
|
||||||
toast.success('Task updated.');
|
toast('Task updated.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data?.data?.data ?? null;
|
return res.data?.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -351,7 +426,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, taskId) =>
|
(taskListId, taskId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`${BASE}/${taskListId}/tasks/${taskId}`);
|
await api.delete(`${BASE}/${taskListId}/tasks/${taskId}`);
|
||||||
toast.success('Task archived.');
|
toast('Task archived.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -361,7 +441,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, taskId) =>
|
(taskListId, taskId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.patch(`${BASE}/${taskListId}/tasks/${taskId}/restore`);
|
await api.patch(`${BASE}/${taskListId}/tasks/${taskId}/restore`);
|
||||||
toast.success('Task restored.');
|
toast('Task restored.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -371,7 +456,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, ids) =>
|
(taskListId, ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/${taskListId}/tasks/bulk-archive`, { ids });
|
await api.post(`${BASE}/${taskListId}/tasks/bulk-archive`, { ids });
|
||||||
toast.success(`${ids.length} task(s) archived.`);
|
toast(`${ids.length} task(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -381,7 +471,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, ids) =>
|
(taskListId, ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/${taskListId}/tasks/bulk-restore`, { ids });
|
await api.post(`${BASE}/${taskListId}/tasks/bulk-restore`, { ids });
|
||||||
toast.success(`${ids.length} task(s) restored.`);
|
toast(`${ids.length} task(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -391,7 +486,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, taskId) =>
|
(taskListId, taskId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`${BASE}/${taskListId}/tasks/${taskId}/permanent`);
|
await api.delete(`${BASE}/${taskListId}/tasks/${taskId}/permanent`);
|
||||||
toast.success('Task permanently deleted.');
|
toast('Task permanently deleted.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -401,7 +501,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
(taskListId, ids) =>
|
(taskListId, ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
await api.post(`${BASE}/${taskListId}/tasks/bulk-delete`, { ids });
|
await api.post(`${BASE}/${taskListId}/tasks/bulk-delete`, { ids });
|
||||||
toast.success(`${ids.length} task(s) permanently deleted.`);
|
toast(`${ids.length} task(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -520,7 +625,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
await api.delete(
|
await api.delete(
|
||||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}`
|
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}`
|
||||||
);
|
);
|
||||||
toast.success('Completion archived.');
|
toast('Completion archived.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[completionRequest]
|
[completionRequest]
|
||||||
@@ -533,7 +643,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
await api.patch(
|
await api.patch(
|
||||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}/restore`
|
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}/restore`
|
||||||
);
|
);
|
||||||
toast.success('Completion restored.');
|
toast('Completion restored.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[completionRequest]
|
[completionRequest]
|
||||||
@@ -547,7 +662,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-archive`,
|
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-archive`,
|
||||||
{ ids }
|
{ ids }
|
||||||
);
|
);
|
||||||
toast.success(`${ids.length} completion(s) archived.`);
|
toast(`${ids.length} completion(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[completionRequest]
|
[completionRequest]
|
||||||
@@ -561,7 +681,12 @@ export function AdminTaskProvider({ children }) {
|
|||||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-restore`,
|
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-restore`,
|
||||||
{ ids }
|
{ ids }
|
||||||
);
|
);
|
||||||
toast.success(`${ids.length} completion(s) restored.`);
|
toast(`${ids.length} completion(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[completionRequest]
|
[completionRequest]
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { return await fn(); }
|
try { return await fn(); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Something went wrong.");
|
toast(err?.response?.data?.message ?? "Something went wrong.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -41,7 +46,12 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
const createCategory = useCallback((payload) =>
|
const createCategory = useCallback((payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.post("/admin/tiers/categories", payload);
|
const { data } = await api.post("/admin/tiers/categories", payload);
|
||||||
toast.success("Tier category created.");
|
toast("Tier category created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
@@ -52,7 +62,12 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
|
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
|
||||||
);
|
);
|
||||||
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
|
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
|
||||||
toast.success("Tier category updated.");
|
toast("Tier category updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request, category]);
|
}), [request, category]);
|
||||||
|
|
||||||
@@ -60,7 +75,12 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`/admin/tiers/categories/${id}`);
|
await api.delete(`/admin/tiers/categories/${id}`);
|
||||||
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
|
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
|
||||||
toast.success("Tier category deleted.");
|
toast("Tier category deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export function AdminTierPoliciesProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -45,7 +50,12 @@ export function AdminTierPoliciesProvider({ children }) {
|
|||||||
? prev.map((b) => (b.key === key ? data.data : b))
|
? prev.map((b) => (b.key === key ? data.data : b))
|
||||||
: [...prev, data.data];
|
: [...prev, data.data];
|
||||||
});
|
});
|
||||||
toast.success("Badge saved.");
|
toast("Badge saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export function AdminTiersProvider({ children }) {
|
|||||||
totalPages: data.data?.pagination?.totalPages ?? 1,
|
totalPages: data.data?.pagination?.totalPages ?? 1,
|
||||||
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
||||||
});
|
});
|
||||||
} catch { toast.error("Could not load plans."); }
|
} catch { toast("Could not load plans.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -42,7 +47,12 @@ export function AdminTiersProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/tiers/${id}`);
|
const { data } = await api.get(`/admin/tiers/${id}`);
|
||||||
setPlan(data.data ?? null);
|
setPlan(data.data ?? null);
|
||||||
} catch { toast.error("Could not load plan."); }
|
} catch { toast("Could not load plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,10 +60,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/admin/tiers", payload);
|
const { data } = await api.post("/admin/tiers", payload);
|
||||||
toast.success("Plan created.");
|
toast("Plan created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not create plan.");
|
toast(err?.response?.data?.message ?? "Could not create plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -62,10 +82,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.put(`/admin/tiers/${id}`, payload);
|
const { data } = await api.put(`/admin/tiers/${id}`, payload);
|
||||||
toast.success("Plan updated.");
|
toast("Plan updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not update plan.");
|
toast(err?.response?.data?.message ?? "Could not update plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -74,10 +104,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/tiers/${id}`);
|
await api.delete(`/admin/tiers/${id}`);
|
||||||
toast.success("Plan archived.");
|
toast("Plan archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not archive plan.");
|
toast(err?.response?.data?.message ?? "Could not archive plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -86,10 +126,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/admin/tiers/${id}/restore`);
|
await api.post(`/admin/tiers/${id}/restore`);
|
||||||
toast.success("Plan restored.");
|
toast("Plan restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not restore plan.");
|
toast(err?.response?.data?.message ?? "Could not restore plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -98,10 +148,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/admin/tiers/bulk/archive", { ids });
|
await api.post("/admin/tiers/bulk/archive", { ids });
|
||||||
toast.success("Plans archived.");
|
toast("Plans archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not archive plans.");
|
toast(err?.response?.data?.message ?? "Could not archive plans.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -110,10 +170,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/admin/tiers/bulk/restore", { ids });
|
await api.post("/admin/tiers/bulk/restore", { ids });
|
||||||
toast.success("Plans restored.");
|
toast("Plans restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not restore plans.");
|
toast(err?.response?.data?.message ?? "Could not restore plans.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -122,10 +192,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/tiers/${id}/permanent`);
|
await api.delete(`/admin/tiers/${id}/permanent`);
|
||||||
toast.success("Plan permanently deleted.");
|
toast("Plan permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not permanently delete plan.");
|
toast(err?.response?.data?.message ?? "Could not permanently delete plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -134,10 +214,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/admin/tiers/bulk/permanent-delete", { ids });
|
await api.post("/admin/tiers/bulk/permanent-delete", { ids });
|
||||||
toast.success("Plans permanently deleted.");
|
toast("Plans permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not permanently delete plans.");
|
toast(err?.response?.data?.message ?? "Could not permanently delete plans.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -162,7 +252,12 @@ export function AdminTiersProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
|
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
|
||||||
setUserTiers(data.data ?? []);
|
setUserTiers(data.data ?? []);
|
||||||
} catch { toast.error("Could not load user tiers."); }
|
} catch { toast("Could not load user tiers.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -170,10 +265,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/admin/tiers/users/tiers/grant", payload);
|
await api.post("/admin/tiers/users/tiers/grant", payload);
|
||||||
toast.success("Tier granted.");
|
toast("Tier granted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not grant tier.");
|
toast(err?.response?.data?.message ?? "Could not grant tier.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -182,10 +287,20 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
|
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
|
||||||
toast.success("Tier revoked.");
|
toast("Tier revoked.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not revoke tier.");
|
toast(err?.response?.data?.message ?? "Could not revoke tier.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -206,7 +321,12 @@ export function AdminTiersProvider({ children }) {
|
|||||||
totalPages: data.data?.pagination?.totalPages ?? 1,
|
totalPages: data.data?.pagination?.totalPages ?? 1,
|
||||||
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
||||||
});
|
});
|
||||||
} catch { toast.error("Could not load payments."); }
|
} catch { toast("Could not load payments.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -215,7 +335,12 @@ export function AdminTiersProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/tiers/payments/${id}`);
|
const { data } = await api.get(`/admin/tiers/payments/${id}`);
|
||||||
setPayment(data.data ?? null);
|
setPayment(data.data ?? null);
|
||||||
} catch { toast.error("Could not load payment."); }
|
} catch { toast("Could not load payment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -108,7 +113,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
(payload) =>
|
(payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/users/staff`, payload);
|
const res = await api.post(`${BASE}/users/staff`, payload);
|
||||||
toast.success("Staff user added successfully.");
|
toast("Staff user added successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -122,7 +132,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
|
||||||
);
|
);
|
||||||
toast.success("User updated successfully.");
|
toast("User updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -136,7 +151,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
|
||||||
);
|
);
|
||||||
toast.success("User deactivated.");
|
toast("User deactivated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -154,7 +174,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
deactivated_ids.includes(u.user_id) ? { ...u, is_active: false } : u
|
deactivated_ids.includes(u.user_id) ? { ...u, is_active: false } : u
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success(`${deactivated_ids.length} user(s) deactivated.`);
|
toast(`${deactivated_ids.length} user(s) deactivated.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -169,7 +194,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
|
||||||
);
|
);
|
||||||
toast.success("User restored.");
|
toast("User restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -187,7 +217,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
restored_ids.includes(u.user_id) ? { ...u, is_active: true } : u
|
restored_ids.includes(u.user_id) ? { ...u, is_active: true } : u
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success(`${restored_ids.length} user(s) restored.`);
|
toast(`${restored_ids.length} user(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -200,7 +235,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/users/${userId}/permanent`);
|
const res = await api.delete(`${BASE}/users/${userId}/permanent`);
|
||||||
setUsers((prev) => prev.filter((u) => u.user_id !== userId));
|
setUsers((prev) => prev.filter((u) => u.user_id !== userId));
|
||||||
toast.success("User permanently deleted.");
|
toast("User permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -214,7 +254,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
const { deleted_ids } = res.data?.data ?? {};
|
const { deleted_ids } = res.data?.data ?? {};
|
||||||
if (deleted_ids?.length) {
|
if (deleted_ids?.length) {
|
||||||
setUsers((prev) => prev.filter((u) => !deleted_ids.includes(u.user_id)));
|
setUsers((prev) => prev.filter((u) => !deleted_ids.includes(u.user_id)));
|
||||||
toast.success(`${deleted_ids.length} user(s) permanently deleted.`);
|
toast(`${deleted_ids.length} user(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -240,7 +285,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setSessions((prev) =>
|
setSessions((prev) =>
|
||||||
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
|
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
|
||||||
);
|
);
|
||||||
toast.success("Session terminated.");
|
toast("Session terminated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -284,7 +334,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
return d;
|
return d;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load activity.");
|
toast(err?.response?.data?.message ?? "Could not load activity.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setActivityLoading(false);
|
setActivityLoading(false);
|
||||||
@@ -311,7 +366,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setAchievements(res.data?.data ?? []);
|
setAchievements(res.data?.data ?? []);
|
||||||
return res.data?.data;
|
return res.data?.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load achievements.");
|
toast(err?.response?.data?.message ?? "Could not load achievements.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
setAchievementsLoading(false);
|
setAchievementsLoading(false);
|
||||||
@@ -327,7 +387,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: true } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: true } : u))
|
||||||
);
|
);
|
||||||
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: true } : prev);
|
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: true } : prev);
|
||||||
toast.success("User banned successfully.");
|
toast("User banned successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, user]
|
[request, user]
|
||||||
@@ -342,7 +407,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: false } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: false } : u))
|
||||||
);
|
);
|
||||||
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: false } : prev);
|
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: false } : prev);
|
||||||
toast.success("User unbanned successfully.");
|
toast("User unbanned successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, user]
|
[request, user]
|
||||||
@@ -358,7 +428,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (banned_ids.includes(u.user_id) ? { ...u, is_banned: true } : u))
|
prev.map((u) => (banned_ids.includes(u.user_id) ? { ...u, is_banned: true } : u))
|
||||||
);
|
);
|
||||||
toast.success(`${banned_ids.length} user(s) banned.`);
|
toast(`${banned_ids.length} user(s) banned.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -375,7 +450,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (unbanned_ids.includes(u.user_id) ? { ...u, is_banned: false } : u))
|
prev.map((u) => (unbanned_ids.includes(u.user_id) ? { ...u, is_banned: false } : u))
|
||||||
);
|
);
|
||||||
toast.success(`${unbanned_ids.length} user(s) unbanned.`);
|
toast(`${unbanned_ids.length} user(s) unbanned.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -390,7 +470,12 @@ export const UserProvider = ({ children }) => {
|
|||||||
setBans(res.data?.data ?? []);
|
setBans(res.data?.data ?? []);
|
||||||
return res.data?.data;
|
return res.data?.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load ban history.");
|
toast(err?.response?.data?.message ?? "Could not load ban history.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
setBansLoading(false);
|
setBansLoading(false);
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -121,7 +126,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/groups`, { name, description, group_code });
|
const res = await api.post(`${BASE}/groups`, { name, description, group_code });
|
||||||
setGroups((prev) => [res.data?.data, ...prev]);
|
setGroups((prev) => [res.data?.data, ...prev]);
|
||||||
toast.success("Group created successfully.");
|
toast("Group created successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -136,7 +146,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
||||||
);
|
);
|
||||||
setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev));
|
setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev));
|
||||||
toast.success("Group updated successfully.");
|
toast("Group updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -150,7 +165,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g))
|
||||||
);
|
);
|
||||||
toast.success("Group deactivated.");
|
toast("Group deactivated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -168,7 +188,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g
|
deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success(`${deactivated_ids.length} group(s) deactivated.`);
|
toast(`${deactivated_ids.length} group(s) deactivated.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -183,7 +208,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g))
|
||||||
);
|
);
|
||||||
toast.success("Group restored.");
|
toast("Group restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -201,7 +231,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g
|
restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success(`${restored_ids.length} group(s) restored.`);
|
toast(`${restored_ids.length} group(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -214,7 +249,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/groups/${gid}/permanent`);
|
const res = await api.delete(`${BASE}/groups/${gid}/permanent`);
|
||||||
setGroups((prev) => prev.filter((g) => g.group_id !== gid));
|
setGroups((prev) => prev.filter((g) => g.group_id !== gid));
|
||||||
toast.success("Group permanently deleted.");
|
toast("Group permanently deleted.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -228,7 +268,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
const { deleted_ids } = res.data?.data ?? {};
|
const { deleted_ids } = res.data?.data ?? {};
|
||||||
if (deleted_ids?.length) {
|
if (deleted_ids?.length) {
|
||||||
setGroups((prev) => prev.filter((g) => !deleted_ids.includes(g.group_id)));
|
setGroups((prev) => prev.filter((g) => !deleted_ids.includes(g.group_id)));
|
||||||
toast.success(`${deleted_ids.length} group(s) permanently deleted.`);
|
toast(`${deleted_ids.length} group(s) permanently deleted.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -241,7 +286,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids });
|
const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids });
|
||||||
setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
toast.success("Users added to group.");
|
toast("Users added to group.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -254,7 +304,12 @@ export function UserGroupProvider({ children }) {
|
|||||||
const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } });
|
const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } });
|
||||||
setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
toast.success("Users removed from group.");
|
toast("Users removed from group.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -22,22 +22,35 @@ export function AuthProvider({ children }) {
|
|||||||
_setAccessToken(token)
|
_setAccessToken(token)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Shared by verifyOTP, restoreSession, and login's trusted-device fast path
|
||||||
|
// — anywhere the backend hands back a fully-authenticated session in one shot.
|
||||||
|
const applySession = useCallback((data) => {
|
||||||
|
setAccessToken(data.accessToken)
|
||||||
|
setUser(data.user)
|
||||||
|
setSessionId(data.session_id ?? null)
|
||||||
|
}, [setAccessToken])
|
||||||
|
|
||||||
// ── Login ──────────────────────────────────────────────────────────────────
|
// ── Login ──────────────────────────────────────────────────────────────────
|
||||||
|
// Credentials get you an OTP — unless this device already cleared one
|
||||||
|
// recently and its trust window is still valid, in which case the backend
|
||||||
|
// returns otpRequired:false along with a full session, same shape as
|
||||||
|
// verifyOTP's response.
|
||||||
const login = useCallback(async ({ email, password }) => {
|
const login = useCallback(async ({ email, password }) => {
|
||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', { email, password })
|
const { data } = await api.post('/auth/login', { email, password })
|
||||||
setAccessToken(data.data.accessToken)
|
if (data.data.otpRequired === false) {
|
||||||
setUser(data.data.user)
|
applySession(data.data)
|
||||||
setSessionId(data.data.session_id ?? null)
|
return { success: true, otpRequired: false, user: data.data.user }
|
||||||
return { success: true, user: data.data.user }
|
}
|
||||||
|
return { success: true, otpRequired: true, email: data.data.email }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.response?.data?.message || 'Login failed. Please try again.'
|
const message = err.response?.data?.message || 'Login failed. Please try again.'
|
||||||
const errors = err.response?.data?.errors ?? null
|
const errors = err.response?.data?.errors ?? null
|
||||||
setAuthError(message)
|
setAuthError(message)
|
||||||
return { success: false, message, errors }
|
return { success: false, message, errors }
|
||||||
}
|
}
|
||||||
}, [])
|
}, [applySession])
|
||||||
|
|
||||||
// ── Register ───────────────────────────────────────────────────────────────
|
// ── Register ───────────────────────────────────────────────────────────────
|
||||||
const register = useCallback(async (payload) => {
|
const register = useCallback(async (payload) => {
|
||||||
@@ -57,16 +70,14 @@ export function AuthProvider({ children }) {
|
|||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/verify-otp', { email, otp })
|
const { data } = await api.post('/auth/verify-otp', { email, otp })
|
||||||
setAccessToken(data.data.accessToken)
|
applySession(data.data)
|
||||||
setUser(data.data.user)
|
|
||||||
setSessionId(data.data.session_id ?? null)
|
|
||||||
return { success: true, user: data.data.user }
|
return { success: true, user: data.data.user }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.response?.data?.message || 'OTP verification failed.'
|
const message = err.response?.data?.message || 'OTP verification failed.'
|
||||||
setAuthError(message)
|
setAuthError(message)
|
||||||
return { success: false, message }
|
return { success: false, message }
|
||||||
}
|
}
|
||||||
}, [])
|
}, [applySession])
|
||||||
|
|
||||||
// ── Resend OTP ─────────────────────────────────────────────────────────────
|
// ── Resend OTP ─────────────────────────────────────────────────────────────
|
||||||
const resendOTP = useCallback(async ({ email }) => {
|
const resendOTP = useCallback(async ({ email }) => {
|
||||||
@@ -79,6 +90,28 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Forgot password (request OTP) ─────────────────────────────────────────
|
||||||
|
const forgotPassword = useCallback(async ({ email }) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/forgot-password', { email })
|
||||||
|
return { success: true, email: data.data.email }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.message || 'Could not process request.'
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Reset password (OTP + new password in one step) ───────────────────────
|
||||||
|
const resetPassword = useCallback(async ({ email, otp, new_password }) => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/reset-password', { email, otp, new_password })
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.message || 'Password reset failed.'
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -94,23 +127,23 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
// ── Restore session ────────────────────────────────────────────────────────
|
// ── Restore session ────────────────────────────────────────────────────────
|
||||||
const restoreSession = useCallback(async () => {
|
const restoreSession = useCallback(async () => {
|
||||||
if (isRestoring.current) return
|
if (isRestoring.current) return { success: false }
|
||||||
isRestoring.current = true
|
isRestoring.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (accessTokenRef.current) return
|
if (accessTokenRef.current) return { success: true }
|
||||||
const { data } = await api.post('/auth/refresh')
|
const { data } = await api.post('/auth/refresh')
|
||||||
setAccessToken(data.data.accessToken)
|
applySession(data.data)
|
||||||
setUser(data.data.user)
|
return { success: true, user: data.data.user }
|
||||||
setSessionId(data.data.session_id ?? null)
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setSessionId(null)
|
setSessionId(null)
|
||||||
|
return { success: false }
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [applySession])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
@@ -124,6 +157,8 @@ export function AuthProvider({ children }) {
|
|||||||
register,
|
register,
|
||||||
verifyOTP,
|
verifyOTP,
|
||||||
resendOTP,
|
resendOTP,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
logout,
|
logout,
|
||||||
loading,
|
loading,
|
||||||
sessionRestored,
|
sessionRestored,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
// dashboard.popup) can be fetched independently without clobbering each other.
|
// dashboard.popup) can be fetched independently without clobbering each other.
|
||||||
const [advertisements, setAdvertisements] = useState({});
|
const [advertisements, setAdvertisements] = useState({});
|
||||||
const [loading, setLoading] = useState({});
|
const [loading, setLoading] = useState({});
|
||||||
|
|
||||||
|
// Keyed by placement, holds the full list for carousel-style slots (e.g.
|
||||||
|
// dashboard.hero) — separate from `advertisements` above, which only ever
|
||||||
|
// holds the single highest-priority ad per placement.
|
||||||
|
const [adLists, setAdLists] = useState({});
|
||||||
|
const [listLoading, setListLoading] = useState({});
|
||||||
const [clickCounts, setClickCounts] = useState(loadClickCounts);
|
const [clickCounts, setClickCounts] = useState(loadClickCounts);
|
||||||
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
|
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
|
||||||
const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false);
|
const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false);
|
||||||
@@ -128,6 +134,33 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
[ensureProfile]
|
[ensureProfile]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ──
|
||||||
|
// Resolves every live ad for one placement — use for carousel-style slots
|
||||||
|
// that rotate through several ads instead of showing just the winner.
|
||||||
|
const getActiveAdvertisementList = useCallback(
|
||||||
|
async (placement, limit) => {
|
||||||
|
setListLoading((prev) => ({ ...prev, [placement]: true }));
|
||||||
|
try {
|
||||||
|
const [currentProfile, { data }] = await Promise.all([
|
||||||
|
ensureProfile(),
|
||||||
|
api.get("/client/advertisements/active-list", { params: { placement, limit } }),
|
||||||
|
]);
|
||||||
|
const raw = data?.data?.data ?? [];
|
||||||
|
const list = raw
|
||||||
|
.map((ad) => resolveVisibility(currentProfile, ad))
|
||||||
|
.filter(Boolean);
|
||||||
|
setAdLists((prev) => ({ ...prev, [placement]: list }));
|
||||||
|
return list;
|
||||||
|
} catch {
|
||||||
|
setAdLists((prev) => ({ ...prev, [placement]: [] }));
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setListLoading((prev) => ({ ...prev, [placement]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ensureProfile]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
|
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
|
||||||
// Fire-and-forget — never await this on a navigation-blocking path.
|
// Fire-and-forget — never await this on a navigation-blocking path.
|
||||||
const trackClick = useCallback(
|
const trackClick = useCallback(
|
||||||
@@ -206,6 +239,9 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
loading,
|
loading,
|
||||||
getActiveAdvertisement,
|
getActiveAdvertisement,
|
||||||
getActiveAdvertisements,
|
getActiveAdvertisements,
|
||||||
|
adLists,
|
||||||
|
listLoading,
|
||||||
|
getActiveAdvertisementList,
|
||||||
trackClick,
|
trackClick,
|
||||||
handleAdCtaClick,
|
handleAdCtaClick,
|
||||||
dismissPopupForever,
|
dismissPopupForever,
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ export function CourseReadingProgressProvider({ children }) {
|
|||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not load course progress.');
|
toast(err?.response?.data?.message ?? 'Could not load course progress.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -101,7 +106,12 @@ export function CourseReadingProgressProvider({ children }) {
|
|||||||
delete next[lessonUuid];
|
delete next[lessonUuid];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not update progress.');
|
toast(err?.response?.data?.message ?? 'Could not update progress.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/courses");
|
const { data } = await api.get("/client/courses");
|
||||||
setCourses(data.data ?? []);
|
setCourses(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load courses.");
|
toast(err?.response?.data?.message ?? "Could not load courses.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setCoursesLoading(false);
|
setCoursesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +63,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
if (err?.response?.status === 403) {
|
if (err?.response?.status === 403) {
|
||||||
setCourseBlocked(true); // let the UI show an upgrade prompt
|
setCourseBlocked(true); // let the UI show an upgrade prompt
|
||||||
} else {
|
} else {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load course.");
|
toast(err?.response?.data?.message ?? "Could not load course.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setCourseLoading(false);
|
setCourseLoading(false);
|
||||||
@@ -71,7 +81,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.get(`/client/courses/${courseId}/units/${unitId}`);
|
const { data } = await api.get(`/client/courses/${courseId}/units/${unitId}`);
|
||||||
setUnit(data.data ?? null);
|
setUnit(data.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load unit.");
|
toast(err?.response?.data?.message ?? "Could not load unit.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setUnitLoading(false);
|
setUnitLoading(false);
|
||||||
}
|
}
|
||||||
@@ -85,7 +100,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
);
|
);
|
||||||
setLesson(data.data ?? null);
|
setLesson(data.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load lesson.");
|
toast(err?.response?.data?.message ?? "Could not load lesson.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLessonLoading(false);
|
setLessonLoading(false);
|
||||||
}
|
}
|
||||||
@@ -99,7 +119,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
);
|
);
|
||||||
setQuiz(data.data ?? null);
|
setQuiz(data.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
|
toast(err?.response?.data?.message ?? "Could not load quiz.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setQuizLoading(false);
|
setQuizLoading(false);
|
||||||
}
|
}
|
||||||
@@ -111,7 +136,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.get(`/client/courses/${courseId}/assessment`);
|
const { data } = await api.get(`/client/courses/${courseId}/assessment`);
|
||||||
setAssessment(data.data ?? null);
|
setAssessment(data.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
|
toast(err?.response?.data?.message ?? "Could not load assessment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setAssessmentLoading(false);
|
setAssessmentLoading(false);
|
||||||
}
|
}
|
||||||
@@ -125,7 +155,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
);
|
);
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not submit quiz.");
|
toast(err?.response?.data?.message ?? "Could not submit quiz.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -135,7 +170,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.post(`/client/courses/${courseId}/assessment/${assessmentId}/start`);
|
const { data } = await api.post(`/client/courses/${courseId}/assessment/${assessmentId}/start`);
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not start assessment.");
|
toast(err?.response?.data?.message ?? "Could not start assessment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -167,7 +207,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
);
|
);
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not submit assessment.");
|
toast(err?.response?.data?.message ?? "Could not submit assessment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -184,7 +229,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/course-purchases");
|
const { data } = await api.get("/client/course-purchases");
|
||||||
setPurchases(data.data ?? []);
|
setPurchases(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load purchases.");
|
toast(err?.response?.data?.message ?? "Could not load purchases.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally { setPurchasesLoading(false); }
|
} finally { setPurchasesLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -194,7 +244,12 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
const { data } = await api.post("/client/course-purchases/order", { product_id: productId });
|
const { data } = await api.post("/client/course-purchases/order", { product_id: productId });
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not create order.");
|
toast(err?.response?.data?.message ?? "Could not create order.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setPurchaseLoading(false); }
|
} finally { setPurchaseLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -203,10 +258,20 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
setPurchaseLoading(true);
|
setPurchaseLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId });
|
const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId });
|
||||||
toast.success("Purchase confirmed! You now have access to this course.");
|
toast("Purchase confirmed! You now have access to this course.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not capture payment.");
|
toast(err?.response?.data?.message ?? "Could not capture payment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally { setPurchaseLoading(false); }
|
} finally { setPurchaseLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ export function GroupProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export function TaskProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -58,7 +63,12 @@ export function TaskProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setCompletionLoading(false);
|
setCompletionLoading(false);
|
||||||
@@ -155,7 +165,12 @@ export function TaskProvider({ children }) {
|
|||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
const data = res.data?.data ?? null;
|
const data = res.data?.data ?? null;
|
||||||
toast.success('Task submitted successfully.');
|
toast('Task submitted successfully.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
// Immediately update latest completion so UI reflects the new state
|
// Immediately update latest completion so UI reflects the new state
|
||||||
setLatestCompletion(data);
|
setLatestCompletion(data);
|
||||||
// Prepend to history if it's already loaded
|
// Prepend to history if it's already loaded
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export function TaskProgressProvider({ children }) {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -44,10 +44,20 @@ export function ClientTiersProvider({ children }) {
|
|||||||
// known-active tier — this prevents the "Free" flash after an upgrade.
|
// known-active tier — this prevents the "Free" flash after an upgrade.
|
||||||
setMyTier(prev => (tier === null && prev?.status === 'active') ? prev : tier);
|
setMyTier(prev => (tier === null && prev?.status === 'active') ? prev : tier);
|
||||||
if (tier?.just_expired) {
|
if (tier?.just_expired) {
|
||||||
toast.warning("Your subscription has expired. You've been moved to the Free plan.");
|
toast("Your subscription has expired. You've been moved to the Free plan.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) toast.error(err?.response?.data?.message ?? "Could not load tier.");
|
if (!silent) toast(err?.response?.data?.message ?? "Could not load tier.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setTierLoading(false);
|
if (!silent) setTierLoading(false);
|
||||||
}
|
}
|
||||||
@@ -78,7 +88,12 @@ export function ClientTiersProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/tiers/me/history");
|
const { data } = await api.get("/client/tiers/me/history");
|
||||||
setTierHistory(data.data ?? []);
|
setTierHistory(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load tier history.");
|
toast(err?.response?.data?.message ?? "Could not load tier history.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTierHistoryLoading(false);
|
setTierHistoryLoading(false);
|
||||||
}
|
}
|
||||||
@@ -90,7 +105,12 @@ export function ClientTiersProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/tiers/plans");
|
const { data } = await api.get("/client/tiers/plans");
|
||||||
setPlans(data.data ?? []);
|
setPlans(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load plans.");
|
toast(err?.response?.data?.message ?? "Could not load plans.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPlansLoading(false);
|
setPlansLoading(false);
|
||||||
}
|
}
|
||||||
@@ -118,7 +138,12 @@ export function ClientTiersProvider({ children }) {
|
|||||||
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not create order.");
|
toast(err?.response?.data?.message ?? "Could not create order.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoading(false);
|
setCheckoutLoading(false);
|
||||||
@@ -130,14 +155,24 @@ export function ClientTiersProvider({ children }) {
|
|||||||
setCheckoutLoading(true);
|
setCheckoutLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
|
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
|
||||||
toast.success(data.message ?? "Payment successful. Tier activated.");
|
toast(data.message ?? "Payment successful. Tier activated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
|
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
|
||||||
// Refresh from server so the browser cache holds fresh Premium data — prevents
|
// Refresh from server so the browser cache holds fresh Premium data — prevents
|
||||||
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
|
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
|
||||||
getMyTier({ silent: true });
|
getMyTier({ silent: true });
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Payment capture failed.");
|
toast(err?.response?.data?.message ?? "Payment capture failed.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoading(false);
|
setCheckoutLoading(false);
|
||||||
@@ -162,7 +197,12 @@ export function ClientTiersProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/tiers/me/payments");
|
const { data } = await api.get("/client/tiers/me/payments");
|
||||||
setPayments(data.data ?? []);
|
setPayments(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load payment history.");
|
toast(err?.response?.data?.message ?? "Could not load payment history.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPaymentsLoading(false);
|
setPaymentsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
setProfile(fresh);
|
setProfile(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load profile.");
|
toast(err?.response?.data?.message ?? "Could not load profile.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setProfileLoading(false);
|
setProfileLoading(false);
|
||||||
@@ -47,10 +52,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
const { data } = await api.put(`${apiBase}/profile`, { personal_info });
|
const { data } = await api.put(`${apiBase}/profile`, { personal_info });
|
||||||
setProfile(data.data ?? null);
|
setProfile(data.data ?? null);
|
||||||
setUser((prev) => ({ ...prev, ...data.data }));
|
setUser((prev) => ({ ...prev, ...data.data }));
|
||||||
toast.success(data.message ?? "Profile updated.");
|
toast(data.message ?? "Profile updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: true, data: data.data };
|
return { success: true, data: data.data };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not update profile.");
|
toast(err?.response?.data?.message ?? "Could not update profile.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} finally {
|
} finally {
|
||||||
setProfileLoading(false);
|
setProfileLoading(false);
|
||||||
@@ -63,7 +78,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
const { data } = await api.get(`${apiBase}/sessions`);
|
const { data } = await api.get(`${apiBase}/sessions`);
|
||||||
setSessions(data.data ?? []);
|
setSessions(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load sessions.");
|
toast(err?.response?.data?.message ?? "Could not load sessions.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSessionsLoading(false);
|
setSessionsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -74,10 +94,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`${apiBase}/sessions/${sessionId}`);
|
await api.delete(`${apiBase}/sessions/${sessionId}`);
|
||||||
setSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
|
setSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
|
||||||
toast.success("Session revoked.");
|
toast("Session revoked.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not revoke session.");
|
toast(err?.response?.data?.message ?? "Could not revoke session.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} finally {
|
} finally {
|
||||||
setRevokingId(null);
|
setRevokingId(null);
|
||||||
@@ -92,10 +122,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
const { data } = await api.post(`${apiBase}/profile/avatar`, formData);
|
const { data } = await api.post(`${apiBase}/profile/avatar`, formData);
|
||||||
setProfile(data.data ?? null);
|
setProfile(data.data ?? null);
|
||||||
setUser((prev) => ({ ...prev, personal_info: data.data?.personal_info }));
|
setUser((prev) => ({ ...prev, personal_info: data.data?.personal_info }));
|
||||||
toast.success('Avatar updated.');
|
toast('Avatar updated.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not update avatar.');
|
toast(err?.response?.data?.message ?? 'Could not update avatar.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
@@ -114,10 +154,20 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
...prev,
|
...prev,
|
||||||
personal_info: { ...(prev?.personal_info ?? {}), avatar: null },
|
personal_info: { ...(prev?.personal_info ?? {}), avatar: null },
|
||||||
}));
|
}));
|
||||||
toast.success('Avatar removed.');
|
toast('Avatar removed.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not remove avatar.');
|
toast(err?.response?.data?.message ?? 'Could not remove avatar.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
@@ -130,7 +180,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
|||||||
const { data } = await api.get(`${apiBase}/achievements`);
|
const { data } = await api.get(`${apiBase}/achievements`);
|
||||||
setAchievements(data.data ?? []);
|
setAchievements(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load achievements.");
|
toast(err?.response?.data?.message ?? "Could not load achievements.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setAchievementsLoading(false);
|
setAchievementsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ export const StaffGroupProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -56,7 +61,12 @@ export const StaffGroupProvider = ({ children }) => {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setMembersLoading(false);
|
setMembersLoading(false);
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export const StaffScoreProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -134,7 +139,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.post(`${BASE}/task-lists`, payload);
|
const res = await api.post(`${BASE}/task-lists`, payload);
|
||||||
const created = res.data?.data;
|
const created = res.data?.data;
|
||||||
if (created) setTaskLists((prev) => [created, ...prev]);
|
if (created) setTaskLists((prev) => [created, ...prev]);
|
||||||
toast.success("Task list created.");
|
toast("Task list created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -153,7 +163,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
if (taskList?.task_list_id === taskListId)
|
if (taskList?.task_list_id === taskListId)
|
||||||
setTaskList((prev) => ({ ...prev, ...updated }));
|
setTaskList((prev) => ({ ...prev, ...updated }));
|
||||||
}
|
}
|
||||||
toast.success("Task list updated.");
|
toast("Task list updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, taskList]
|
[request, taskList]
|
||||||
@@ -166,7 +181,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.post(`${BASE}/task-lists/${taskListId}/archive`);
|
const res = await api.post(`${BASE}/task-lists/${taskListId}/archive`);
|
||||||
setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
|
setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
|
||||||
if (taskList?.task_list_id === taskListId) setTaskList(null);
|
if (taskList?.task_list_id === taskListId) setTaskList(null);
|
||||||
toast.success("Task list archived.");
|
toast("Task list archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, taskList]
|
[request, taskList]
|
||||||
@@ -178,7 +198,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/task-lists/${taskListId}/restore`);
|
const res = await api.post(`${BASE}/task-lists/${taskListId}/restore`);
|
||||||
setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
|
setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
|
||||||
toast.success("Task list restored.");
|
toast("Task list restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -193,7 +218,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
setTaskLists((prev) =>
|
setTaskLists((prev) =>
|
||||||
prev.filter((tl) => !archived_ids.includes(tl.task_list_id))
|
prev.filter((tl) => !archived_ids.includes(tl.task_list_id))
|
||||||
);
|
);
|
||||||
toast.success(`${archived_ids.length} task list(s) archived.`);
|
toast(`${archived_ids.length} task list(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -208,7 +238,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
setTaskLists((prev) =>
|
setTaskLists((prev) =>
|
||||||
prev.filter((tl) => !restored_ids.includes(tl.task_list_id))
|
prev.filter((tl) => !restored_ids.includes(tl.task_list_id))
|
||||||
);
|
);
|
||||||
toast.success(`${restored_ids.length} task list(s) restored.`);
|
toast(`${restored_ids.length} task list(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -290,7 +325,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
toast.success("Task created.");
|
toast("Task created.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -320,7 +360,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
if (task?.task_id === taskId) setTask((prev) => ({ ...prev, ...updated }));
|
if (task?.task_id === taskId) setTask((prev) => ({ ...prev, ...updated }));
|
||||||
}
|
}
|
||||||
toast.success("Task updated.");
|
toast("Task updated.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, task]
|
[request, task]
|
||||||
@@ -339,7 +384,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
: tl
|
: tl
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success("Task archived.");
|
toast("Task archived.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -351,7 +401,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/${taskId}/restore`);
|
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/${taskId}/restore`);
|
||||||
setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
|
setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
|
||||||
toast.success("Task restored.");
|
toast("Task restored.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -364,7 +419,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-archive`, { ids });
|
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-archive`, { ids });
|
||||||
const { archived_ids = [] } = res.data?.data ?? {};
|
const { archived_ids = [] } = res.data?.data ?? {};
|
||||||
setTasks((prev) => prev.filter((t) => !archived_ids.includes(t.task_id)));
|
setTasks((prev) => prev.filter((t) => !archived_ids.includes(t.task_id)));
|
||||||
toast.success(`${archived_ids.length} task(s) archived.`);
|
toast(`${archived_ids.length} task(s) archived.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -377,7 +437,12 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-restore`, { ids });
|
const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-restore`, { ids });
|
||||||
const { restored_ids = [] } = res.data?.data ?? {};
|
const { restored_ids = [] } = res.data?.data ?? {};
|
||||||
setTasks((prev) => prev.filter((t) => !restored_ids.includes(t.task_id)));
|
setTasks((prev) => prev.filter((t) => !restored_ids.includes(t.task_id)));
|
||||||
toast.success(`${restored_ids.length} task(s) restored.`);
|
toast(`${restored_ids.length} task(s) restored.`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ export const StaffUserProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
|
|||||||
|
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import UserMenu from "@/components/generic/UserMenu"
|
import UserMenu from "@/components/generic/UserMenu"
|
||||||
|
|||||||
@@ -248,7 +248,12 @@ export default function CourseAssessment() {
|
|||||||
setLocalAssessment(result);
|
setLocalAssessment(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.response?.status !== 404) {
|
if (err?.response?.status !== 404) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
|
toast(err?.response?.data?.message ?? "Could not load assessment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setInitializing(false);
|
setInitializing(false);
|
||||||
|
|||||||
@@ -355,7 +355,12 @@ export default function ViewAssessment() {
|
|||||||
setLocalAssessment(data?.data?.data ?? null);
|
setLocalAssessment(data?.data?.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.response?.status !== 404) {
|
if (err?.response?.status !== 404) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
|
toast(err?.response?.data?.message ?? "Could not load assessment.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -240,7 +240,12 @@ export default function ModifyQuiz() {
|
|||||||
setLocalQuiz(result);
|
setLocalQuiz(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.response?.status !== 404) {
|
if (err?.response?.status !== 404) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
|
toast(err?.response?.data?.message ?? "Could not load quiz.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// 404 → no quiz yet, stay in create mode with localQuiz = null
|
// 404 → no quiz yet, stay in create mode with localQuiz = null
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ export default function NotificationSettings() {
|
|||||||
const { data } = await api.get("/admin/notification-settings");
|
const { data } = await api.get("/admin/notification-settings");
|
||||||
setSettings(data?.data ?? []);
|
setSettings(data?.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Failed to load notification settings.");
|
toast(err?.response?.data?.message ?? "Failed to load notification settings.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,9 +63,22 @@ export default function NotificationSettings() {
|
|||||||
});
|
});
|
||||||
const updated = data?.data?.data;
|
const updated = data?.data?.data;
|
||||||
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
|
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
|
||||||
toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`);
|
toast(
|
||||||
|
`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`,
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Failed to update setting.");
|
toast(err?.response?.data?.message ?? "Failed to update setting.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingJob(null);
|
setSavingJob(null);
|
||||||
}
|
}
|
||||||
@@ -75,9 +93,19 @@ export default function NotificationSettings() {
|
|||||||
});
|
});
|
||||||
const updated = data?.data?.data;
|
const updated = data?.data?.data;
|
||||||
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
|
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
|
||||||
toast.success("Schedule updated — took effect immediately, no restart needed.");
|
toast("Schedule updated — took effect immediately, no restart needed.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Failed to update schedule.");
|
toast(err?.response?.data?.message ?? "Failed to update schedule.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingJob(null);
|
setSavingJob(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,20 @@ export default function PaymentPolicy() {
|
|||||||
},
|
},
|
||||||
promo_rules: promoRules,
|
promo_rules: promoRules,
|
||||||
});
|
});
|
||||||
toast.success("Payment policy saved.");
|
toast("Payment policy saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
navigate("/admin/tiers/plans");
|
navigate("/admin/tiers/plans");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
|
toast(err?.response?.data?.message ?? "Could not save payment policy.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -113,10 +123,25 @@ export default function PaymentPolicy() {
|
|||||||
|
|
||||||
const handleAddPromo = () => {
|
const handleAddPromo = () => {
|
||||||
const code = addForm.code.trim().toUpperCase();
|
const code = addForm.code.trim().toUpperCase();
|
||||||
if (!code) { toast.error("Code is required."); return; }
|
if (!code) { toast("Code is required.", {
|
||||||
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return; }
|
||||||
|
if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return; }
|
||||||
if (promoRules.some((r) => r.code.toUpperCase() === code)) {
|
if (promoRules.some((r) => r.code.toUpperCase() === code)) {
|
||||||
toast.error("A rule with this code already exists."); return;
|
toast("A rule with this code already exists.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rule = {
|
const rule = {
|
||||||
|
|||||||
@@ -220,9 +220,19 @@ function PaymentPolicyTab({ planId, plan }) {
|
|||||||
},
|
},
|
||||||
promo_rules: promoRules,
|
promo_rules: promoRules,
|
||||||
});
|
});
|
||||||
toast.success("Payment policy saved.");
|
toast("Payment policy saved.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
|
toast(err?.response?.data?.message ?? "Could not save payment policy.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -230,9 +240,24 @@ function PaymentPolicyTab({ planId, plan }) {
|
|||||||
|
|
||||||
const handleAddPromo = () => {
|
const handleAddPromo = () => {
|
||||||
const code = addForm.code.trim().toUpperCase();
|
const code = addForm.code.trim().toUpperCase();
|
||||||
if (!code) { toast.error("Code is required."); return; }
|
if (!code) { toast("Code is required.", {
|
||||||
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
|
action: {
|
||||||
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; }
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return; }
|
||||||
|
if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return; }
|
||||||
|
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast("A rule with this code already exists.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}); return; }
|
||||||
|
|
||||||
const rule = {
|
const rule = {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -151,10 +151,20 @@ export default function EditUser() {
|
|||||||
|
|
||||||
const res = await updateUser(id, payload);
|
const res = await updateUser(id, payload);
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success("User updated successfully.");
|
toast("User updated successfully.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
navigate(`../view/${id}`);
|
navigate(`../view/${id}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to update user.");
|
toast("Failed to update user.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: ForgotPasswordForm.jsx
|
||||||
|
* Type of Program: Frontend Component
|
||||||
|
* Description: Password reset flow — same procedure for every acc_type (admin,
|
||||||
|
* staff, user); only reg_type matters (Google accounts are turned
|
||||||
|
* away, see LoginForm's "Please log in with Google" precedent).
|
||||||
|
* Step 1 — Email (checks reg_type server-side, sends OTP if system)
|
||||||
|
* Step 2 — OTP + new password + confirm, single submit
|
||||||
|
* Module: User Credentials
|
||||||
|
* Author: lash0000
|
||||||
|
* Date Created: Jul. 4, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const emailSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetSchema = z
|
||||||
|
.object({
|
||||||
|
otp: z.string().length(6, 'Enter all 6 digits').regex(/^\d{6}$/, 'OTP must contain only digits'),
|
||||||
|
new_password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
|
||||||
|
.regex(/[0-9]/, 'Must contain at least one number'),
|
||||||
|
confirm_password: z.string().min(1, 'Please confirm your password'),
|
||||||
|
})
|
||||||
|
.refine((d) => d.new_password === d.confirm_password, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirm_password'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ForgotPasswordForm({ className, ...props }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { forgotPassword, resetPassword } = useAuth()
|
||||||
|
|
||||||
|
// 0 = email, 1 = otp + new password
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [pendingEmail, setPendingEmail] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(0)
|
||||||
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [successDialogOpen, setSuccessDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCooldown <= 0) return
|
||||||
|
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [resendCooldown])
|
||||||
|
|
||||||
|
// ── Step 1: Email ──────────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
register: regEmail,
|
||||||
|
handleSubmit: submitEmail,
|
||||||
|
formState: { errors: errEmail, isSubmitting: isRequesting },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(emailSchema),
|
||||||
|
defaultValues: { email: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onEmailSubmit = async ({ email }) => {
|
||||||
|
const result = await forgotPassword({ email })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setErrorMessage(result.message)
|
||||||
|
setErrorDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingEmail(email)
|
||||||
|
setResendCooldown(30)
|
||||||
|
setStep(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: OTP + new password ────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
control: resetControl,
|
||||||
|
register: regReset,
|
||||||
|
handleSubmit: submitReset,
|
||||||
|
formState: { errors: errReset, isSubmitting: isResetting },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(resetSchema),
|
||||||
|
defaultValues: { otp: '', new_password: '', confirm_password: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onResetSubmit = async ({ otp, new_password }) => {
|
||||||
|
const result = await resetPassword({ email: pendingEmail, otp, new_password })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setErrorMessage(result.message)
|
||||||
|
setErrorDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (resendCooldown > 0) return
|
||||||
|
|
||||||
|
const result = await forgotPassword({ email: pendingEmail })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setErrorMessage(result.message)
|
||||||
|
setErrorDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResendCooldown(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{/* ── Step 1: Email ── */}
|
||||||
|
{step === 0 && (
|
||||||
|
<form onSubmit={submitEmail(onEmailSubmit)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Forgot your password?</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Enter your email and we'll send you a code to reset it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">Email address</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isRequesting}
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.removeAttribute('readonly')}
|
||||||
|
{...regEmail('email')}
|
||||||
|
/>
|
||||||
|
{errEmail.email && (
|
||||||
|
<p className="text-xs text-destructive">{errEmail.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isRequesting}>
|
||||||
|
{isRequesting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Send reset code'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Remembered it?{' '}
|
||||||
|
<Link to="/login" className="font-medium underline underline-offset-4">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: OTP + new password ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<form onSubmit={submitReset(onResetSubmit)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Reset your password.</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
We sent a 6-digit code to{' '}
|
||||||
|
<span className="font-medium text-foreground">{pendingEmail}</span>.
|
||||||
|
Enter it below along with your new password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-center">Verification code</label>
|
||||||
|
<Controller
|
||||||
|
control={resetControl}
|
||||||
|
name="otp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
containerClassName="justify-center"
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<InputOTPSlot key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errReset.otp && (
|
||||||
|
<p className="text-xs text-destructive text-center">{errReset.otp.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm -mt-2">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 h-auto font-medium"
|
||||||
|
disabled={resendCooldown > 0}
|
||||||
|
onClick={handleResend}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New password */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="new_password" className="text-sm font-medium">New password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="new_password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Min. 8 chars, 1 uppercase, 1 number"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isResetting}
|
||||||
|
className="pr-10"
|
||||||
|
{...regReset('new_password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errReset.new_password && (
|
||||||
|
<p className="text-xs text-destructive">{errReset.new_password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm password */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="confirm_password" className="text-sm font-medium">Confirm new password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
placeholder="Repeat your new password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isResetting}
|
||||||
|
className="pr-10"
|
||||||
|
{...regReset('confirm_password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errReset.confirm_password && (
|
||||||
|
<p className="text-xs text-destructive">{errReset.confirm_password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isResetting}>
|
||||||
|
{isResetting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
Resetting...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Submit'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-muted-foreground"
|
||||||
|
onClick={() => setStep(0)}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Dialog */}
|
||||||
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Something went wrong</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Success Dialog */}
|
||||||
|
<AlertDialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Password changed</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Your password has been reset successfully. Please log in with your new password.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => navigate('/login')}>Go to login</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import { useForm } from 'react-hook-form'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { getRoleHomePath } from '@/utils/roleRedirect.util'
|
||||||
|
import { OtpVerifyForm } from './OtpVerifyForm'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -48,6 +50,7 @@ export function LoginForm({ className, ...props }) {
|
|||||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [otpEmail, setOtpEmail] = useState(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -58,16 +61,21 @@ export function LoginForm({ className, ...props }) {
|
|||||||
defaultValues: { email: '', password: '' },
|
defaultValues: { email: '', password: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleAuthSuccess = (user) => {
|
||||||
|
navigate(getRoleHomePath(user))
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async ({ email, password }) => {
|
const onSubmit = async ({ email, password }) => {
|
||||||
const result = await login({ email, password })
|
const result = await login({ email, password })
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
switch (result.user.acc_type) {
|
if (result.otpRequired === false) {
|
||||||
case 'admin': navigate('/admin'); break
|
// Trusted device — session was issued directly, no OTP step needed.
|
||||||
case 'staff': navigate('/staff'); break
|
handleAuthSuccess(result.user)
|
||||||
case 'client': navigate('/client'); break
|
return
|
||||||
default: navigate('/login')
|
|
||||||
}
|
}
|
||||||
|
// Credentials confirmed — an OTP was emailed. Tokens aren't issued yet.
|
||||||
|
setOtpEmail(result.email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +98,18 @@ export function LoginForm({ className, ...props }) {
|
|||||||
window.location.href = '/api/auth/google'
|
window.location.href = '/api/auth/google'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (otpEmail) {
|
||||||
|
return (
|
||||||
|
<OtpVerifyForm
|
||||||
|
email={otpEmail}
|
||||||
|
onSuccess={handleAuthSuccess}
|
||||||
|
title="Verify your sign-in"
|
||||||
|
description={`We sent a 6-digit code to ${otpEmail} to finish signing in. It expires in 10 minutes.`}
|
||||||
|
onBack={() => setOtpEmail(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: OtpVerifyForm.jsx
|
||||||
|
* Type of Program: Frontend Component
|
||||||
|
* Description: Shared OTP step used by every auth path — registration email
|
||||||
|
* verification, post-password login, and post-Google login.
|
||||||
|
* All three funnel through POST /auth/verify-otp, which mints
|
||||||
|
* tokens/session on success regardless of which path led here.
|
||||||
|
* Module: User Credentials
|
||||||
|
* Author: lash0000
|
||||||
|
* Date Created: Jul. 4, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { LoaderCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const otpSchema = z.object({
|
||||||
|
otp: z
|
||||||
|
.string()
|
||||||
|
.length(6, 'Enter all 6 digits')
|
||||||
|
.regex(/^\d{6}$/, 'OTP must contain only digits'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} email — the account this OTP was sent to
|
||||||
|
* @param {function} onSuccess — called with the authenticated user on success
|
||||||
|
* @param {string} [title]
|
||||||
|
* @param {string|JSX.Element} [description]
|
||||||
|
* @param {function} [onBack] — if provided, renders a "back" action (e.g. registration's "back to credentials")
|
||||||
|
*/
|
||||||
|
export function OtpVerifyForm({ email, onSuccess, title = 'Check your email.', description, onBack }) {
|
||||||
|
const { verifyOTP, resendOTP } = useAuth()
|
||||||
|
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(30)
|
||||||
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCooldown <= 0) return
|
||||||
|
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [resendCooldown])
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting: isVerifying },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(otpSchema),
|
||||||
|
defaultValues: { otp: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVerify = async ({ otp }) => {
|
||||||
|
const result = await verifyOTP({ email, otp })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setErrorMessage(result.message)
|
||||||
|
setErrorDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess(result.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (resendCooldown > 0) return
|
||||||
|
|
||||||
|
const result = await resendOTP({ email })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setErrorMessage(result.message)
|
||||||
|
setErrorDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResendCooldown(30)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit(onVerify)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">{title}</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
{description ?? (
|
||||||
|
<>
|
||||||
|
We sent a 6-digit code to{' '}
|
||||||
|
<span className="font-medium text-foreground">{email}</span>.
|
||||||
|
It expires in 10 minutes.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="otp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
containerClassName="justify-center"
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<InputOTPSlot key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.otp && (
|
||||||
|
<p className="text-xs text-destructive text-center">{errors.otp.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isVerifying}>
|
||||||
|
{isVerifying ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Verify'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 h-auto font-medium"
|
||||||
|
disabled={resendCooldown > 0}
|
||||||
|
onClick={handleResend}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onBack && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<Button type="button" variant="ghost" className="w-full text-muted-foreground" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Verification failed</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,18 +15,18 @@
|
|||||||
* May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project
|
* May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||||
* May 23, 2026 lash0000 002 birthday + occupation required; all calls via useAuth (register, verifyOTP, resendOTP)
|
* May 23, 2026 lash0000 002 birthday + occupation required; all calls via useAuth (register, verifyOTP, resendOTP)
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { OtpVerifyForm } from './OtpVerifyForm'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -72,13 +72,6 @@ const credentialsSchema = z
|
|||||||
path: ['confirm_password'],
|
path: ['confirm_password'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const otpSchema = z.object({
|
|
||||||
otp: z
|
|
||||||
.string()
|
|
||||||
.length(6, 'Enter all 6 digits')
|
|
||||||
.regex(/^\d{6}$/, 'OTP must contain only digits'),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Stepper indicator ────────────────────────────────────────────────────────
|
// ─── Stepper indicator ────────────────────────────────────────────────────────
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: 'Personal info' },
|
{ label: 'Personal info' },
|
||||||
@@ -134,58 +127,10 @@ function StepIndicator({ current }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OTP Cell Input ───────────────────────────────────────────────────────────
|
|
||||||
function OtpInput({ value = '', onChange }) {
|
|
||||||
const cellRefs = Array.from({ length: 6 }, () => useRef(null))
|
|
||||||
const digits = value.split('')
|
|
||||||
|
|
||||||
const handleChange = (i, e) => {
|
|
||||||
const char = e.target.value.replace(/\D/g, '').slice(-1)
|
|
||||||
const next = [...digits]
|
|
||||||
next[i] = char
|
|
||||||
onChange(next.join(''))
|
|
||||||
if (char && i < 5) cellRefs[i + 1].current?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (i, e) => {
|
|
||||||
if (e.key === 'Backspace' && !digits[i] && i > 0) {
|
|
||||||
const next = [...digits]
|
|
||||||
next[i - 1] = ''
|
|
||||||
onChange(next.join(''))
|
|
||||||
cellRefs[i - 1].current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
|
|
||||||
onChange(pasted)
|
|
||||||
cellRefs[Math.min(pasted.length, 5)].current?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 justify-center" onPaste={handlePaste}>
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Input
|
|
||||||
key={i}
|
|
||||||
ref={cellRefs[i]}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={1}
|
|
||||||
value={digits[i] || ''}
|
|
||||||
onChange={(e) => handleChange(i, e)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
||||||
className="w-11 h-12 text-center text-lg font-semibold p-0"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RegisterForm ─────────────────────────────────────────────────────────────
|
// ─── RegisterForm ─────────────────────────────────────────────────────────────
|
||||||
export function RegisterForm({ className, ...props }) {
|
export function RegisterForm({ className, ...props }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { register: authRegister, verifyOTP, resendOTP } = useAuth()
|
const { register: authRegister } = useAuth()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const groupCode = searchParams.get('group_code') || ''
|
const groupCode = searchParams.get('group_code') || ''
|
||||||
|
|
||||||
@@ -194,20 +139,12 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
const [pendingEmail, setPendingEmail] = useState('')
|
const [pendingEmail, setPendingEmail] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
const [resendCooldown, setResendCooldown] = useState(0)
|
|
||||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
// Accumulated data across steps
|
// Accumulated data across steps
|
||||||
const [personalData, setPersonalData] = useState({})
|
const [personalData, setPersonalData] = useState({})
|
||||||
|
|
||||||
// ── Resend countdown ──────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendCooldown <= 0) return
|
|
||||||
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [resendCooldown])
|
|
||||||
|
|
||||||
// ── Step 1: Personal info ─────────────────────────────────────────────────
|
// ── Step 1: Personal info ─────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
register: regPersonal,
|
register: regPersonal,
|
||||||
@@ -278,48 +215,9 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPendingEmail(email)
|
setPendingEmail(email)
|
||||||
setResendCooldown(30)
|
|
||||||
setStep(2)
|
setStep(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3: OTP ───────────────────────────────────────────────────────────
|
|
||||||
const {
|
|
||||||
control: otpControl,
|
|
||||||
handleSubmit: submitOtp,
|
|
||||||
reset: resetOtp,
|
|
||||||
formState: { errors: errOtp, isSubmitting: isVerifying },
|
|
||||||
} = useForm({
|
|
||||||
resolver: zodResolver(otpSchema),
|
|
||||||
defaultValues: { otp: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const onVerifyOTP = async ({ otp }) => {
|
|
||||||
const result = await verifyOTP({ email: pendingEmail, otp })
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setErrorMessage(result.message)
|
|
||||||
setErrorDialogOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/dashboard', { state: { justRegistered: true } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResend = async () => {
|
|
||||||
if (resendCooldown > 0) return
|
|
||||||
|
|
||||||
const result = await resendOTP({ email: pendingEmail })
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setErrorMessage(result.message)
|
|
||||||
setErrorDialogOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setResendCooldown(30)
|
|
||||||
resetOtp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -608,76 +506,18 @@ export function RegisterForm({ className, ...props }) {
|
|||||||
|
|
||||||
{/* ── Step 3: OTP ── */}
|
{/* ── Step 3: OTP ── */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<form onSubmit={submitOtp(onVerifyOTP)} className="flex flex-col gap-5">
|
<OtpVerifyForm
|
||||||
<div className="flex flex-col gap-1">
|
email={pendingEmail}
|
||||||
<h1 className="text-2xl font-bold tracking-tighter">Check your email.</h1>
|
onSuccess={() => navigate('/dashboard', { state: { justRegistered: true } })}
|
||||||
<p className="text-muted-foreground text-sm text-balance">
|
onBack={() => setStep(1)}
|
||||||
We sent a 6-digit code to{' '}
|
|
||||||
<span className="font-medium text-foreground">{pendingEmail}</span>.
|
|
||||||
It expires in 10 minutes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Controller
|
|
||||||
control={otpControl}
|
|
||||||
name="otp"
|
|
||||||
render={({ field }) => (
|
|
||||||
<OtpInput value={field.value} onChange={field.onChange} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{errOtp.otp && (
|
|
||||||
<p className="text-xs text-destructive text-center">{errOtp.otp.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isVerifying}>
|
|
||||||
{isVerifying ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
Verifying...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Verify & sign in'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="p-0 h-auto font-medium"
|
|
||||||
disabled={resendCooldown > 0}
|
|
||||||
onClick={handleResend}
|
|
||||||
>
|
|
||||||
Resend code
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full text-muted-foreground"
|
|
||||||
onClick={() => { setStep(1); resetOtp() }}
|
|
||||||
>
|
|
||||||
← Back to credentials
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>Registration failed</AlertDialogTitle>
|
||||||
{step === 2 ? 'Verification failed' : 'Registration failed'}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ export default function ChangePassword() {
|
|||||||
// Update local user state so must_change_password is cleared
|
// Update local user state so must_change_password is cleared
|
||||||
setUser((prev) => ({ ...prev, must_change_password: false }));
|
setUser((prev) => ({ ...prev, must_change_password: false }));
|
||||||
|
|
||||||
toast.success('Password changed successfully. Welcome!');
|
toast('Password changed successfully. Welcome!', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect to the correct dashboard
|
// Redirect to the correct dashboard
|
||||||
switch (user?.acc_type) {
|
switch (user?.acc_type) {
|
||||||
@@ -104,7 +109,12 @@ export default function ChangePassword() {
|
|||||||
default: navigate('/');
|
default: navigate('/');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: ForgotPassword.jsx
|
||||||
|
* Type of Program: Frontend Page
|
||||||
|
* Description: Forgot-password page. Route: /forgot-password
|
||||||
|
* Module: User Credentials
|
||||||
|
* Author: lash0000
|
||||||
|
* Date Created: Jul. 4, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ForgotPasswordForm } from '../components/ForgotPasswordForm'
|
||||||
|
import { MetadataProvider } from '@/contexts/MetadataContext'
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
return (
|
||||||
|
<MetadataProvider
|
||||||
|
value={{
|
||||||
|
title: 'Forgot Password - Philproperties',
|
||||||
|
description: 'Reset your account password.',
|
||||||
|
keywords: 'forgot password, reset password, philproperties',
|
||||||
|
ogTitle: 'Forgot Password - Philproperties',
|
||||||
|
ogDescription: 'Reset your account password.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link to="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="xs:w-40 sm:w-48 md:w-56 2xl:w-64 block dark:hidden">
|
||||||
|
<img src="/philpro-white.png" alt="Philproperties" />
|
||||||
|
</div>
|
||||||
|
<div className="xs:w-40 sm:w-48 md:w-56 2xl:w-64 hidden dark:block">
|
||||||
|
<img src="/philpro-dark.png" alt="Philproperties" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src="https://cq5as7pc73.ufs.sh/f/pHNnzIw3VjcgzIbC8SR4stTZRKXP3cfbD6e2p9jmdAUQBuox"
|
||||||
|
alt="Philproperties"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover rounded-3xl p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MetadataProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import api from '@/utils/api.util'
|
import api from '@/utils/api.util'
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -120,7 +120,15 @@ export default function IntroPage() {
|
|||||||
setUser(prev => ({ ...prev, ...data.data }))
|
setUser(prev => ({ ...prev, ...data.data }))
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? 'Could not save your info. Please try again.')
|
toast(
|
||||||
|
err?.response?.data?.message ?? 'Could not save your info. Please try again.',
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,40 @@
|
|||||||
* Type of Program: Frontend Page
|
* Type of Program: Frontend Page
|
||||||
* Description: Landing page after the backend completes Google OIDC.
|
* Description: Landing page after the backend completes Google OIDC.
|
||||||
*
|
*
|
||||||
* Happy path → backend set the refreshToken cookie and redirected here.
|
* OTP path → backend confirmed the Google identity but, like every other
|
||||||
* App.jsx's restoreSession() fires on mount, picks up the cookie,
|
* login path, still gates on an OTP before issuing tokens. It
|
||||||
* and calls /auth/refresh → sets user. PublicRoute then redirects
|
* redirects here with ?otpRequired=true&email=<email> and has
|
||||||
* to the appropriate dashboard. This page shows a loading spinner
|
* NOT set a refresh cookie yet. This page renders the shared
|
||||||
* for the brief moment before that redirect fires.
|
* OtpVerifyForm; once verified, AuthContext has user/tokens
|
||||||
|
* set and we navigate to the account's home route ourselves.
|
||||||
|
*
|
||||||
|
* Trusted path → this device already cleared an OTP recently and its trust
|
||||||
|
* window is still valid. Backend redirects with
|
||||||
|
* ?otpRequired=false, having already set the refresh cookie —
|
||||||
|
* this page calls restoreSession() to pull the access token
|
||||||
|
* from it, then navigates to the account's home route.
|
||||||
*
|
*
|
||||||
* Error path → backend could not complete OIDC (state mismatch, token exchange
|
* Error path → backend could not complete OIDC (state mismatch, token exchange
|
||||||
* failure, deactivated account, etc.). It redirected here with
|
* failure, deactivated account, etc.). It redirected here with
|
||||||
* ?error=<code>. No refresh cookie was set, so restoreSession()
|
* ?error=<code>. No refresh cookie was set, so restoreSession()
|
||||||
* will fail and the user stays on this page to see the error.
|
* will fail and the user stays on this page to see the error.
|
||||||
*
|
*
|
||||||
|
* Fallback → neither param present (shouldn't normally happen now that the
|
||||||
|
* backend always redirects with one or the other) — falls back
|
||||||
|
* to the old spinner + restoreSession()/PublicRoute behavior.
|
||||||
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 21, 2026
|
* Date Created: Jun. 21, 2026
|
||||||
|
* Date Modified: Jul. 5, 2026 — trusted-device OTP skip path
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
import { useSearchParams, Link } from 'react-router-dom'
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||||
import { LoaderCircle, ShieldBan, UserX, AlertTriangle, RefreshCw, Clock } from 'lucide-react'
|
import { LoaderCircle, ShieldBan, UserX, AlertTriangle, RefreshCw, Clock } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { useDateFormat } from '@/hooks/useDateFormat'
|
import { useDateFormat } from '@/hooks/useDateFormat'
|
||||||
|
import { getRoleHomePath } from '@/utils/roleRedirect.util'
|
||||||
|
import { OtpVerifyForm } from '@/modules/auth/components/OtpVerifyForm'
|
||||||
|
|
||||||
const ERROR_MAP = {
|
const ERROR_MAP = {
|
||||||
access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' },
|
access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' },
|
||||||
@@ -32,8 +48,47 @@ const ERROR_MAP = {
|
|||||||
|
|
||||||
export default function OAuthCallback() {
|
export default function OAuthCallback() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { restoreSession } = useAuth()
|
||||||
const { fmtDateTime } = useDateFormat()
|
const { fmtDateTime } = useDateFormat()
|
||||||
const error = searchParams.get('error')
|
const error = searchParams.get('error')
|
||||||
|
const otpRequiredParam = searchParams.get('otpRequired')
|
||||||
|
const otpRequired = otpRequiredParam === 'true'
|
||||||
|
const trusted = otpRequiredParam === 'false'
|
||||||
|
const email = searchParams.get('email')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trusted) return
|
||||||
|
restoreSession().then(({ success, user }) => {
|
||||||
|
navigate(success ? getRoleHomePath(user) : '/login', { replace: true })
|
||||||
|
})
|
||||||
|
}, [trusted])
|
||||||
|
|
||||||
|
if (trusted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<LoaderCircle className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Signing you in...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpRequired && email) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<OtpVerifyForm
|
||||||
|
email={email}
|
||||||
|
onSuccess={(user) => navigate(getRoleHomePath(user), { replace: true })}
|
||||||
|
title="Verify your sign-in"
|
||||||
|
description={`We sent a 6-digit code to ${email} to finish signing in with Google. It expires in 10 minutes.`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error === 'account_banned') {
|
if (error === 'account_banned') {
|
||||||
const reason = searchParams.get('reason')
|
const reason = searchParams.get('reason')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import LandingLayout from '@/modules/public/layouts/LandingLayout'
|
|||||||
import LandingPage from '@/modules/public/pages/LandingPage'
|
import LandingPage from '@/modules/public/pages/LandingPage'
|
||||||
import Login from '../pages/Login'
|
import Login from '../pages/Login'
|
||||||
import Register from '../pages/Register'
|
import Register from '../pages/Register'
|
||||||
|
import ForgotPassword from '../pages/ForgotPassword'
|
||||||
import OAuthCallback from '../pages/OAuthCallback'
|
import OAuthCallback from '../pages/OAuthCallback'
|
||||||
import Suspended from '@/modules/public/pages/Suspended'
|
import Suspended from '@/modules/public/pages/Suspended'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export const AuthRoutes = {
|
|||||||
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
|
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
|
||||||
{ path: "login", element: <Login />},
|
{ path: "login", element: <Login />},
|
||||||
{ path: "signup", element: <Register />},
|
{ path: "signup", element: <Register />},
|
||||||
|
{ path: "forgot-password", element: <ForgotPassword /> },
|
||||||
{ path: "auth/callback/google", element: <OAuthCallback /> },
|
{ path: "auth/callback/google", element: <OAuthCallback /> },
|
||||||
{ path: "suspended", element: <Suspended /> },
|
{ path: "suspended", element: <Suspended /> },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -178,9 +178,15 @@ const FileUpload = ({
|
|||||||
return ok;
|
return ok;
|
||||||
});
|
});
|
||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
setTimeout(() => toast.error(
|
setTimeout(() => toast(
|
||||||
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
|
`${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);
|
), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,14 +196,26 @@ const FileUpload = ({
|
|||||||
if (maxFileCount) {
|
if (maxFileCount) {
|
||||||
const availableSlots = maxFileCount - prev.length;
|
const availableSlots = maxFileCount - prev.length;
|
||||||
if (availableSlots <= 0) {
|
if (availableSlots <= 0) {
|
||||||
setTimeout(() => toast.error(
|
setTimeout(() => toast(
|
||||||
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
|
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`,
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
), 0);
|
), 0);
|
||||||
incoming = [];
|
incoming = [];
|
||||||
} else if (incoming.length > availableSlots) {
|
} else if (incoming.length > availableSlots) {
|
||||||
setTimeout(() => toast.error(
|
setTimeout(() => toast(
|
||||||
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
|
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
|
||||||
`(max ${maxFileCount}).`
|
`(max ${maxFileCount}).`,
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
), 0);
|
), 0);
|
||||||
incoming = incoming.slice(0, availableSlots);
|
incoming = incoming.slice(0, availableSlots);
|
||||||
}
|
}
|
||||||
@@ -222,11 +240,14 @@ const FileUpload = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
setTimeout(() => toast.error(
|
setTimeout(() => toast(duplicates.length === 1
|
||||||
duplicates.length === 1
|
|
||||||
? `"${duplicates[0]}" is already attached.`
|
? `"${duplicates[0]}" is already attached.`
|
||||||
: `${duplicates.length} files are already attached.`
|
: `${duplicates.length} files are already attached.`, {
|
||||||
), 0);
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
}), 0);
|
||||||
}
|
}
|
||||||
const next = prev.concat(toAdd);
|
const next = prev.concat(toAdd);
|
||||||
toAdd.forEach((e) => simulateUpload(e.id));
|
toAdd.forEach((e) => simulateUpload(e.id));
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
courses.forEach((course) => {
|
courses.forEach((course) => {
|
||||||
const prev = prevCompletedRef.current[course.id];
|
const prev = prevCompletedRef.current[course.id];
|
||||||
if (course.completed && prev === false) {
|
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;
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { SendHorizonal } from "lucide-react";
|
import { SendHorizonal } from "lucide-react";
|
||||||
|
|
||||||
@@ -20,47 +20,41 @@ const normalizeUrl = (url) => {
|
|||||||
return `https://${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) => {
|
const getDomain = (url) => {
|
||||||
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
|
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
|
||||||
catch { return url; }
|
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 ──────────────────────────────────────────────────────────────────
|
// ── LinkCard ──────────────────────────────────────────────────────────────────
|
||||||
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
|
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);
|
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 domain = getDomain(link.url);
|
||||||
const displayImage = meta.image ?? null;
|
|
||||||
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
||||||
const displayTitle = meta.title ?? link.label ?? domain;
|
const displayTitle = link.label ?? domain;
|
||||||
const displayDescription = meta.description ?? link.url;
|
const displayDescription = link.url;
|
||||||
|
|
||||||
const handleTurnIn = async () => {
|
const handleTurnIn = async () => {
|
||||||
await onTurnIn(link.requirement_id);
|
await onTurnIn(link.requirement_id);
|
||||||
@@ -75,39 +69,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="relative w-72 shrink-0 pt-0">
|
<Card className="relative w-72 shrink-0 pt-0">
|
||||||
{loading ? (
|
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-t-lg" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="line-clamp-1">
|
<CardTitle className="line-clamp-1">{displayTitle}</CardTitle>
|
||||||
{loading
|
<CardDescription className="truncate text-xs">{displayDescription}</CardDescription>
|
||||||
? <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>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
{visited ? (
|
{visited ? (
|
||||||
@@ -137,11 +102,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3 w-fit">
|
||||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
<Button asChild variant="link" className="text-blue-500">
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="size-4" /> Open Link
|
<ExternalLink /> {link.url}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,11 +120,6 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
|
<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">
|
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
|
||||||
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
|
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{displayImage ? (
|
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-lg" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<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">
|
<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.
|
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet, useMatches, useNavigate } from "react-router-dom"
|
import { Outlet, useMatches, useNavigate } from "react-router-dom"
|
||||||
import { ThemeSwitcher } from "../components/ThemeSwitcher"
|
import { ThemeSwitcher } from "../components/ThemeSwitcher"
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
import * as LucideIcons from "lucide-react"
|
import * as LucideIcons from "lucide-react"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { useAuth } from "@/contexts/AuthContext"
|
import { useAuth } from "@/contexts/AuthContext"
|
||||||
import api from "@/utils/api.util"
|
import api from "@/utils/api.util"
|
||||||
import { ClientProvider } from "@/contexts/provider/ClientProvider"
|
import { ClientProvider } from "@/contexts/provider/ClientProvider"
|
||||||
@@ -141,6 +142,7 @@ function getInitials(name = "") {
|
|||||||
function ClientNav() {
|
function ClientNav() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
// Background fetches only — nav rendering never waits on these
|
// Background fetches only — nav rendering never waits on these
|
||||||
const { achievements, getAchievements } = useProfile()
|
const { achievements, getAchievements } = useProfile()
|
||||||
@@ -212,6 +214,7 @@ function ClientNav() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
|
setTheme('light')
|
||||||
navigate("/login")
|
navigate("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +298,7 @@ function ClientNav() {
|
|||||||
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
||||||
<Settings /> Account Settings
|
<Settings /> Account Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
{/* <DropdownMenuItem>
|
||||||
<TableOfContents /> Documentation
|
<TableOfContents /> Documentation
|
||||||
<DropdownMenuShortcut>
|
<DropdownMenuShortcut>
|
||||||
<SquareArrowOutUpRight />
|
<SquareArrowOutUpRight />
|
||||||
@@ -306,7 +309,7 @@ function ClientNav() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
|
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
|
||||||
<Gift /> Refer
|
<Gift /> Refer
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem> */}
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
Theme
|
Theme
|
||||||
<DropdownMenuShortcut>
|
<DropdownMenuShortcut>
|
||||||
@@ -343,14 +346,18 @@ const ClientLayout = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientProvider>
|
<ClientProvider>
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
<ClientNav />
|
<ClientNav />
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</div>
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
{showFooter && (
|
{showFooter && (
|
||||||
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
<footer className="bg-muted border-t w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||||
© Philproperties, 2026
|
© Philproperties, 2026
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -63,11 +63,21 @@ function SecuritySection({ user, logout }) {
|
|||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (form.new_password !== form.confirm) {
|
if (form.new_password !== form.confirm) {
|
||||||
toast.error("New passwords do not match.");
|
toast("New passwords do not match.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (form.new_password.length < 8) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -76,13 +86,23 @@ function SecuritySection({ user, logout }) {
|
|||||||
current_password: form.current_password,
|
current_password: form.current_password,
|
||||||
new_password: form.new_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 () => {
|
setTimeout(async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
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 ───────────────────────────────────────────────────────────
|
// ─── Advertisements ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AD_OPTIONS = [
|
const AD_OPTIONS = [
|
||||||
@@ -372,7 +287,13 @@ function AdvertisementsSection() {
|
|||||||
}
|
}
|
||||||
const result = await updateProfile({ [key]: value });
|
const result = await updateProfile({ [key]: value });
|
||||||
if (result?.success) {
|
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);
|
setConfirmPopupOff(false);
|
||||||
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
|
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
|
||||||
if (result?.success) {
|
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 handleHideAllToggle = async (hide) => {
|
||||||
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
|
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
|
||||||
if (result?.success) {
|
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 ───────────────────────────────────────────────────────────
|
// ─── Delete Account ───────────────────────────────────────────────────────────
|
||||||
|
// Disabled while still in development — keep implemented for when we're ready
|
||||||
function DeleteAccountSection({ logout }) {
|
// to expose self-service account deletion.
|
||||||
const navigate = useNavigate();
|
//
|
||||||
const [open, setOpen] = useState(false);
|
// function DeleteAccountSection({ logout }) {
|
||||||
const [loading, setLoading] = useState(false);
|
// const navigate = useNavigate();
|
||||||
|
// const [open, setOpen] = useState(false);
|
||||||
const handleDelete = async () => {
|
// const [loading, setLoading] = useState(false);
|
||||||
setLoading(true);
|
//
|
||||||
try {
|
// const handleDelete = async () => {
|
||||||
await api.delete("/client/profile");
|
// setLoading(true);
|
||||||
toast.success("Account deleted. Goodbye!");
|
// try {
|
||||||
await logout();
|
// await api.delete("/client/profile");
|
||||||
navigate("/login");
|
// toast("Account deleted. Goodbye!", {
|
||||||
} catch (err) {
|
// action: {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not delete account.");
|
// label: "Close",
|
||||||
setLoading(false);
|
// onClick: () => {}
|
||||||
}
|
// }
|
||||||
};
|
// });
|
||||||
|
// await logout();
|
||||||
return (
|
// navigate("/login");
|
||||||
<>
|
// } catch (err) {
|
||||||
<div className="flex items-start justify-between gap-4">
|
// toast(err?.response?.data?.message ?? "Could not delete account.", {
|
||||||
<div className="space-y-1">
|
// action: {
|
||||||
<p className="text-sm font-medium text-destructive">Delete account</p>
|
// label: "Close",
|
||||||
<p className="text-xs text-muted-foreground">
|
// onClick: () => {}
|
||||||
Permanently remove your account and all associated data. This action cannot be undone.
|
// }
|
||||||
</p>
|
// });
|
||||||
</div>
|
// setLoading(false);
|
||||||
<Button
|
// }
|
||||||
variant="destructive"
|
// };
|
||||||
size="sm"
|
//
|
||||||
className="shrink-0"
|
// return (
|
||||||
onClick={() => setOpen(true)}
|
// <>
|
||||||
>
|
// <div className="flex items-start justify-between gap-4">
|
||||||
<Trash2 className="size-3.5 mr-1.5" />
|
// <div className="space-y-1">
|
||||||
Delete account
|
// <p className="text-sm font-medium text-destructive">Delete account</p>
|
||||||
</Button>
|
// <p className="text-xs text-muted-foreground">
|
||||||
</div>
|
// Permanently remove your account and all associated data. This action cannot be undone.
|
||||||
|
// </p>
|
||||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
// </div>
|
||||||
<AlertDialogContent>
|
// <Button
|
||||||
<AlertDialogHeader>
|
// variant="destructive"
|
||||||
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
// size="sm"
|
||||||
<AlertDialogDescription>
|
// className="shrink-0"
|
||||||
This will permanently delete your account and sign you out of all sessions.
|
// onClick={() => setOpen(true)}
|
||||||
Your data cannot be recovered after deletion.
|
// >
|
||||||
</AlertDialogDescription>
|
// <Trash2 className="size-3.5 mr-1.5" />
|
||||||
</AlertDialogHeader>
|
// Delete account
|
||||||
<AlertDialogFooter>
|
// </Button>
|
||||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
// </div>
|
||||||
<AlertDialogAction
|
//
|
||||||
onClick={handleDelete}
|
// <AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
disabled={loading}
|
// <AlertDialogContent>
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
// <AlertDialogHeader>
|
||||||
>
|
// <AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||||
{loading ? "Deleting…" : "Yes, delete my account"}
|
// <AlertDialogDescription>
|
||||||
</AlertDialogAction>
|
// This will permanently delete your account and sign you out of all sessions.
|
||||||
</AlertDialogFooter>
|
// Your data cannot be recovered after deletion.
|
||||||
</AlertDialogContent>
|
// </AlertDialogDescription>
|
||||||
</AlertDialog>
|
// </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 ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -539,21 +484,13 @@ export default function AccountSettings() {
|
|||||||
<SubscriptionSection />
|
<SubscriptionSection />
|
||||||
</Section>
|
</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.">
|
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
|
||||||
<AdvertisementsSection />
|
<AdvertisementsSection />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
|
{/* <Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
|
||||||
<DeleteAccountSection logout={logout} />
|
<DeleteAccountSection logout={logout} />
|
||||||
</Section>
|
</Section> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,12 @@ const Checkout = () => {
|
|||||||
if (!wasCancelled) return;
|
if (!wasCancelled) return;
|
||||||
const orderId = searchParams.get("token");
|
const orderId = searchParams.get("token");
|
||||||
if (orderId) cancelOrder(orderId);
|
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 });
|
navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
|
||||||
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -153,9 +158,19 @@ const Checkout = () => {
|
|||||||
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
|
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
|
||||||
if (result?.valid) {
|
if (result?.valid) {
|
||||||
setPromoResult(result);
|
setPromoResult(result);
|
||||||
toast.success("Promo code applied.");
|
toast("Promo code applied.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} 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;
|
if (!order) return;
|
||||||
const approvalUrl = order.approval_url;
|
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;
|
window.location.href = approvalUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ export default function CourseCheckout() {
|
|||||||
if (!wasCancelled) return;
|
if (!wasCancelled) return;
|
||||||
const orderId = searchParams.get("token");
|
const orderId = searchParams.get("token");
|
||||||
if (orderId) cancelCourseOrder(orderId);
|
if (orderId) cancelCourseOrder(orderId);
|
||||||
toast.info("Payment was cancelled.");
|
toast("Payment was cancelled.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
navigate(`/course/${courseId}/checkout`, { replace: true });
|
navigate(`/course/${courseId}/checkout`, { replace: true });
|
||||||
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -76,7 +81,12 @@ export default function CourseCheckout() {
|
|||||||
if (!course?.product?.id) return;
|
if (!course?.product?.id) return;
|
||||||
const order = await createCourseOrder(course.product.id);
|
const order = await createCourseOrder(course.product.id);
|
||||||
if (!order) return;
|
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;
|
window.location.href = order.approval_url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import api from "@/utils/api.util";
|
|||||||
import {
|
import {
|
||||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||||
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
||||||
Hourglass,
|
Hourglass, Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -44,7 +45,6 @@ function formatDuration(seconds = 0) {
|
|||||||
// ─── Spine / card helpers ──────────────────────────────────────────────────────
|
// ─── Spine / card helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const INTRO_HEIGHT = 50;
|
const INTRO_HEIGHT = 50;
|
||||||
const CX = 0;
|
|
||||||
|
|
||||||
const useVisibleNodes = (refs, count) => {
|
const useVisibleNodes = (refs, count) => {
|
||||||
const [visible, setVisible] = useState(new Set());
|
const [visible, setVisible] = useState(new Set());
|
||||||
@@ -388,19 +388,21 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
|
|||||||
const lastMid = mids.length ? mids[mids.length - 1] : 0;
|
const lastMid = mids.length ? mids[mids.length - 1] : 0;
|
||||||
const svgH = lastMid + 40;
|
const svgH = lastMid + 40;
|
||||||
const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0;
|
const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0;
|
||||||
|
const isIssued = !!certificate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className="flex gap-5 px-4">
|
<div ref={wrapRef} className="flex gap-5 px-4">
|
||||||
{/* Spine */}
|
{/* 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 && (
|
{mids.length > 0 && (
|
||||||
|
<>
|
||||||
<svg
|
<svg
|
||||||
className="absolute top-0 left-0 overflow-visible xs:hidden lg:block"
|
className="absolute top-0 left-1/2 -translate-x-1/2 overflow-visible text-border xs:hidden lg:block"
|
||||||
width={12}
|
width={2}
|
||||||
height={svgH}
|
height={svgH}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
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} />
|
<line x1={1} y1={0} x2={1} y2={svgH} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
{Array.from({ length: 10 }).map((_, i) => {
|
{Array.from({ length: 10 }).map((_, i) => {
|
||||||
const y1 = (mids[0] / 10) * i;
|
const y1 = (mids[0] / 10) * i;
|
||||||
const y2 = (mids[0] / 10) * (i + 1);
|
const y2 = (mids[0] / 10) * (i + 1);
|
||||||
@@ -408,46 +410,47 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
|
|||||||
return (
|
return (
|
||||||
<motion.line
|
<motion.line
|
||||||
key={`intro-${i}`}
|
key={`intro-${i}`}
|
||||||
x1={CX} y1={y1} x2={CX} y2={y2}
|
x1={1} y1={y1} x2={1} y2={y2}
|
||||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||||
animate={{ opacity: revealed ? (i + 1) / 10 : 0 }}
|
animate={{ opacity: revealed ? 1 : 0.3 }}
|
||||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{mids[0] != null && drawnTo > mids[0] && (
|
{mids[0] != null && drawnTo > mids[0] && (
|
||||||
<motion.line
|
<motion.line
|
||||||
x1={CX} y1={mids[0]} x2={CX} y2={drawnTo}
|
x1={1} y1={mids[0]} x2={1} y2={drawnTo}
|
||||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
initial={{ opacity: 0.3 }} animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</svg>
|
||||||
{mids.map((mid, i) => {
|
{mids.map((mid, i) => {
|
||||||
const visible = visibleNodes.has(i);
|
const visible = visibleNodes.has(i);
|
||||||
// Last node (certificate) gets a gold fill
|
|
||||||
const isCert = i === totalNodes - 1;
|
const isCert = i === totalNodes - 1;
|
||||||
return (
|
return (
|
||||||
<g key={`node-${i}`}>
|
<motion.div
|
||||||
<motion.circle
|
key={`node-${i}`}
|
||||||
cx={CX} cy={mid} r={isCert ? 7 : 5}
|
className={cn(
|
||||||
fill={isCert ? "#D4A017" : "currentColor"}
|
"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",
|
||||||
stroke={isCert ? "#D4A017" : "currentColor"}
|
isCert && isIssued ? "border-emerald-500 text-emerald-500" : "border-border text-muted-foreground"
|
||||||
strokeWidth="1.5"
|
)}
|
||||||
|
style={{ top: mid }}
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
|
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
|
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
|
||||||
style={{ transformOrigin: `${CX}px ${mid}px` }}
|
>
|
||||||
/>
|
{isCert ? <Check className="size-3.5" /> : i + 1}
|
||||||
</g>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards */}
|
{/* 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) => {
|
{nodes.map((node, ni) => {
|
||||||
const delay = ni * 0.05;
|
const delay = ni * 0.05;
|
||||||
const nodeRef = (el) => (cardRefs.current[ni] = el);
|
const nodeRef = (el) => (cardRefs.current[ni] = el);
|
||||||
@@ -563,7 +566,12 @@ const CourseDetails = () => {
|
|||||||
}, [course?.badge_asset_id, course?.badge_image_url]);
|
}, [course?.badge_asset_id, course?.badge_image_url]);
|
||||||
|
|
||||||
if (courseBlocked) {
|
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 });
|
navigate("/course", { replace: true });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -625,10 +633,20 @@ const CourseDetails = () => {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="bg-muted dark:bg-accent/50">
|
<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-8">
|
<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 items={items} /></div>
|
<div>
|
||||||
<div className="flex lg:flex-row items-start justify-between w-full">
|
<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 flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -661,7 +679,7 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="w-fit"
|
className="w-fit bg-blue-500"
|
||||||
onClick={() => navigate(`/course/${courseId}/unit`)}
|
onClick={() => navigate(`/course/${courseId}/unit`)}
|
||||||
>
|
>
|
||||||
{hasCompleted
|
{hasCompleted
|
||||||
@@ -688,25 +706,32 @@ const CourseDetails = () => {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-0 lg:py-8">
|
<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 lg:flex-row gap-8">
|
||||||
<div className="flex flex-col gap-4 flex-1 min-w-0">
|
<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="font-bold text-2xl">About this course</div>
|
||||||
<div className="max-w-3xl space-y-4 lg:text-lg">
|
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
|
||||||
<p>{course?.description ?? ""}</p>
|
<p>{course?.description ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Objectives */}
|
{/* Objectives */}
|
||||||
|
<div className="space-y-4">
|
||||||
{course?.objectives?.length > 0 && (
|
{course?.objectives?.length > 0 && (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<div className="font-bold text-2xl">What you will learn</div>
|
<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">
|
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
|
||||||
{course.objectives.map((obj) => (
|
{course.objectives.map((obj) => (
|
||||||
<li key={obj.objective_id}>{obj.text}</li>
|
<li key={obj.objective_id}>{obj.text}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Units — while content isn't ready, only Rewards is shown */}
|
{/* Units — while content isn't ready, only Rewards is shown */}
|
||||||
|
<div className="space-y-4">
|
||||||
{course?.units?.length > 0 && (
|
{course?.units?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="font-bold text-2xl">
|
<div className="font-bold text-2xl">
|
||||||
@@ -729,8 +754,10 @@ const CourseDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advertisement Sidebar */}
|
{/* 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"] ? (
|
{adLoading["course_details.sidebar"] ? (
|
||||||
<SidebarSkeleton />
|
<SidebarSkeleton />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ const CoursesList = () => {
|
|||||||
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
|
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search courses..."
|
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}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
/>
|
/>
|
||||||
@@ -272,30 +272,25 @@ const CoursesList = () => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product category chips */}
|
|
||||||
{allCategories.length > 0 && (
|
{allCategories.length > 0 && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
|
||||||
<button
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
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"}`}
|
<SelectValue placeholder="Category" />
|
||||||
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
All
|
<SelectItem value="All">All Categories</SelectItem>
|
||||||
</button>
|
|
||||||
{allCategories.map((cat) => (
|
{allCategories.map((cat) => (
|
||||||
<button
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||||
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}
|
{cat.name}
|
||||||
</button>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advertisement Banner */}
|
{/* Advertisement Banner */}
|
||||||
{adLoading["course_list.banner"] ? (
|
{adLoading["course_list.banner"] ? (
|
||||||
@@ -315,7 +310,7 @@ const CoursesList = () => {
|
|||||||
<p className="text-md">No courses found</p>
|
<p className="text-md">No courses found</p>
|
||||||
</div>
|
</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) => (
|
{paginated.map((course) => (
|
||||||
<CourseCard
|
<CourseCard
|
||||||
key={course.course_id}
|
key={course.course_id}
|
||||||
|
|||||||
@@ -207,7 +207,11 @@ const Client = () => {
|
|||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
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 [modalOpen, setModalOpen] = useState(false);
|
||||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||||
@@ -216,15 +220,19 @@ const Client = () => {
|
|||||||
|
|
||||||
const userTier = myTier?.tier ?? "free";
|
const userTier = myTier?.tier ?? "free";
|
||||||
|
|
||||||
const heroAd = advertisements["dashboard.hero"] ?? null;
|
const heroAds = adLists["dashboard.hero"] ?? [];
|
||||||
const popupAd = advertisements["dashboard.popup"] ?? null;
|
const popupAd = advertisements["dashboard.popup"] ?? null;
|
||||||
|
|
||||||
// Show welcome toast on first registration
|
// Show welcome toast on first registration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navState?.justRegistered) return;
|
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.',
|
description: 'You earned the Early Access badge. Check your notifications for details.',
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.history.replaceState({}, '');
|
window.history.replaceState({}, '');
|
||||||
}, []);
|
}, []);
|
||||||
@@ -238,11 +246,12 @@ const Client = () => {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ── Resolve active hero + popup ads once on mount ────────────────────────
|
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => {
|
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
|
||||||
if (result["dashboard.popup"]) setPopupOpen(true);
|
if (result["dashboard.popup"]) setPopupOpen(true);
|
||||||
});
|
});
|
||||||
|
getActiveAdvertisementList("dashboard.hero");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -268,13 +277,13 @@ const Client = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="my-20">
|
<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 ── */}
|
{/* ── Hero Advertisement Carousel ── */}
|
||||||
{adLoading["dashboard.hero"] ? (
|
{listLoading["dashboard.hero"] ? (
|
||||||
<HeroSkeleton />
|
<HeroSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
|
<Hero ads={heroAds} onCtaClick={handleAdCtaClick} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── My Groups ── */}
|
{/* ── My Groups ── */}
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeI
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Could not download certificate.");
|
toast("Could not download certificate.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,18 @@ export default function Notifications() {
|
|||||||
const ok = await clearAll();
|
const ok = await clearAll();
|
||||||
setClearing(false);
|
setClearing(false);
|
||||||
setClearOpen(false);
|
setClearOpen(false);
|
||||||
if (ok) toast.success("All notifications cleared.");
|
if (ok) toast("All notifications cleared.", {
|
||||||
else toast.error("Could not clear notifications.");
|
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;
|
const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1;
|
||||||
@@ -176,7 +186,7 @@ export default function Notifications() {
|
|||||||
Mark all as read
|
Mark all as read
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* <Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 text-destructive hover:text-destructive"
|
className="gap-1.5 text-destructive hover:text-destructive"
|
||||||
@@ -185,7 +195,7 @@ export default function Notifications() {
|
|||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
Clear all
|
Clear all
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -385,12 +385,22 @@ export default function PlanList() {
|
|||||||
setRefundLoading(true);
|
setRefundLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/client/tiers/checkout/refund");
|
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);
|
setRefundPlan(null);
|
||||||
resetMyTier();
|
resetMyTier();
|
||||||
getMyTier();
|
getMyTier();
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setRefundLoading(false);
|
setRefundLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function resolveTierBadge(myTier) {
|
|||||||
colorKey: category.color ?? "green",
|
colorKey: category.color ?? "green",
|
||||||
label: category.badge_label ?? category.name ?? tier,
|
label: category.badge_label ?? category.name ?? tier,
|
||||||
description: "",
|
description: "",
|
||||||
information: "",
|
information: category.description ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,12 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badg
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Could not download certificate.");
|
toast("Could not download certificate.", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,7 +507,12 @@ const UnitList = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!completedTasks.length) return;
|
if (!completedTasks.length) return;
|
||||||
completedTasks.forEach((t) => {
|
completedTasks.forEach((t) => {
|
||||||
toast.success(`"${t.task_name}" automatically turned in!`);
|
toast(`"${t.task_name}" automatically turned in!`, {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
clearCompletedTasks();
|
clearCompletedTasks();
|
||||||
}, [completedTasks]);
|
}, [completedTasks]);
|
||||||
|
|||||||
@@ -114,19 +114,7 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
|||||||
<h2 className="font-semibold text-base">Requirements</h2>
|
<h2 className="font-semibold text-base">Requirements</h2>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{items.map(({ key, label, icon, getValue }) => {
|
{provided.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { done, total, binary } = getValue();
|
const { done, total, binary } = getValue();
|
||||||
const complete = total > 0 && done >= total;
|
const complete = total > 0 && done >= total;
|
||||||
return (
|
return (
|
||||||
@@ -152,9 +140,14 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
|||||||
<div className="flex flex-col gap-2 pt-2 border-t">
|
<div className="flex flex-col gap-2 pt-2 border-t">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">Overall progress</span>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -322,7 +315,12 @@ const ViewTask = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!uploadedFiles.length) {
|
if (!uploadedFiles.length) {
|
||||||
toast.error('No files were uploaded successfully.');
|
toast('No files were uploaded successfully.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +334,12 @@ const ViewTask = () => {
|
|||||||
setNote('');
|
setNote('');
|
||||||
setUploadState({ files: [], isUploading: false });
|
setUploadState({ files: [], isUploading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Failed to submit. Please try again.');
|
toast('Failed to submit. Please try again.', {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -410,7 +413,7 @@ const ViewTask = () => {
|
|||||||
<div className="grid lg:grid-cols-[1fr_350px] gap-6 items-start">
|
<div className="grid lg:grid-cols-[1fr_350px] gap-6 items-start">
|
||||||
|
|
||||||
{/* ── Left ──────────────────────────────────────────────── */}
|
{/* ── 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 */}
|
{/* Task header card */}
|
||||||
<div className="border rounded-lg bg-card overflow-hidden">
|
<div className="border rounded-lg bg-card overflow-hidden">
|
||||||
@@ -448,10 +451,6 @@ const ViewTask = () => {
|
|||||||
{/* Requirements section */}
|
{/* Requirements section */}
|
||||||
{!isResolving && requirements.length > 0 && (
|
{!isResolving && requirements.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-4 text-lg">
|
|
||||||
<h1>Requirements</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* visit_link */}
|
{/* visit_link */}
|
||||||
{visitLinkReqs.length > 0 && (
|
{visitLinkReqs.length > 0 && (
|
||||||
<VisitLink
|
<VisitLink
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function Footer() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
|
||||||
{/* Resources */}
|
{/* Resources */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Resources</h3>
|
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Resources</h3>
|
||||||
<ul className="space-y-3 text-muted-foreground">
|
<ul className="space-y-3 text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link to="">Philpro Learnings</Link>
|
<Link to="">Philpro Learnings</Link>
|
||||||
@@ -39,7 +39,7 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Company */}
|
{/* Company */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Company</h3>
|
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Company</h3>
|
||||||
<ul className="space-y-3 text-muted-foreground">
|
<ul className="space-y-3 text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link to="">Philpro Learnings</Link>
|
<Link to="">Philpro Learnings</Link>
|
||||||
@@ -58,7 +58,7 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Socials */}
|
{/* Socials */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Socials</h3>
|
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Socials</h3>
|
||||||
<ul className="space-y-3 text-muted-foreground">
|
<ul className="space-y-3 text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link to="">Philpro Learnings</Link>
|
<Link to="">Philpro Learnings</Link>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function LandingPage() {
|
|||||||
<GitCompare /> Alpha Testing
|
<GitCompare /> Alpha Testing
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="xs:text-5xl lg:text-6xl font-bold tracking-tighter leading-tight text-primary max-w-2xl md:text-center break-words">
|
<h1 className="xs:text-5xl lg:text-6xl font-bold tracking-tighter leading-tight text-primary dark:text-white max-w-2xl md:text-center break-words">
|
||||||
Fueling Growth, Elevate your performance
|
Fueling Growth, Elevate your performance
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-xl">Access the application, Achieve the transformation.</p>
|
<p className="text-muted-foreground text-xl">Access the application, Achieve the transformation.</p>
|
||||||
@@ -86,7 +86,7 @@ function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
To-do
|
To-do
|
||||||
<Badge
|
<Badge
|
||||||
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
|
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>3</Badge>
|
>3</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -100,7 +100,7 @@ function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
Pending
|
Pending
|
||||||
<Badge
|
<Badge
|
||||||
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
|
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>8</Badge>
|
>8</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -114,7 +114,7 @@ function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
Completed
|
Completed
|
||||||
<Badge
|
<Badge
|
||||||
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 -mr-1 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
|
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 -mr-1 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>20</Badge>
|
>20</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -141,7 +141,7 @@ function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Call to Action */}
|
{/* Call to Action */}
|
||||||
<div id="about" className="xs:p-8 lg:p-12 flex flex-col xs:text-2xl lg:text-4xl font-bold tracking-tighter text-primary gap-8 hover:bg-muted dark:hover:bg-muted/20">
|
<div id="about" className="xs:p-8 lg:p-12 flex flex-col xs:text-2xl lg:text-4xl font-bold tracking-tighter text-primary dark:text-white gap-8 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<div>
|
<div>
|
||||||
“The Sales Training and Recruitment (STAR) makes building a winning sales team simple. From hiring the right people to fast-tracking their skills, it combines smart recruitment, clear onboarding, and practical training to create confident, high-performing professionals.”
|
“The Sales Training and Recruitment (STAR) makes building a winning sales team simple. From hiring the right people to fast-tracking their skills, it combines smart recruitment, clear onboarding, and practical training to create confident, high-performing professionals.”
|
||||||
</div>
|
</div>
|
||||||
@@ -153,15 +153,15 @@ function LandingPage() {
|
|||||||
{/* For sales, why choose us? */}
|
{/* For sales, why choose us? */}
|
||||||
<div className="grid xs:grid-cols-1 lg:grid-cols-3 border-t">
|
<div className="grid xs:grid-cols-1 lg:grid-cols-3 border-t">
|
||||||
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<h1 className="text-primary font-bold tracking-tighter text-4xl">Hire Smarter</h1>
|
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Hire Smarter</h1>
|
||||||
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
|
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<h1 className="text-primary font-bold tracking-tighter text-4xl">Train Better</h1>
|
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Train Better</h1>
|
||||||
<p className="text-muted-foreground">Equip every recruit with clear onboarding, mandatory modules, and practical sales training.</p>
|
<p className="text-muted-foreground">Equip every recruit with clear onboarding, mandatory modules, and practical sales training.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 xs:p-8 lg:p-12 xs:border-b hover:bg-muted dark:hover:bg-muted/20">
|
<div className="flex flex-col gap-4 xs:p-8 lg:p-12 xs:border-b hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<h1 className="text-primary font-bold tracking-tighter text-4xl">Grow</h1>
|
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Grow</h1>
|
||||||
<p className="text-muted-foreground">Build confident professionals, reduce turnover, and boost long-term sales performance.</p>
|
<p className="text-muted-foreground">Build confident professionals, reduce turnover, and boost long-term sales performance.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +170,7 @@ function LandingPage() {
|
|||||||
<div className="grid xs:grid-cols-1 lg:grid-cols-2">
|
<div className="grid xs:grid-cols-1 lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-4 lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
<div className="flex flex-col gap-4 lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-primary font-bold tracking-tighter text-4xl">Frequently Asked Questions</h1>
|
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Frequently Asked Questions</h1>
|
||||||
<p className="text-muted-foreground">Here are useful questions.</p>
|
<p className="text-muted-foreground">Here are useful questions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -187,14 +187,14 @@ function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="contacts" className="flex flex-col gap-4 xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
<div id="contacts" className="flex flex-col gap-4 xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-primary font-bold tracking-tighter text-4xl">Contact Us</h1>
|
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Contact Us</h1>
|
||||||
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
|
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{ContactData.map(({ id, icon: Icon, label, value }) => (
|
{ContactData.map(({ id, icon: Icon, label, value }) => (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className="flex items-center justify-between bg-primary dark:bg-blue-600 rounded-md px-4 py-2 text-sm text-primary-foreground dark:text-white"
|
className="flex items-center justify-between bg-primary dark:bg-blue-600 rounded-md px-4 py-2 text-sm text-primary dark:text-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-wrap gap-2">
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
@@ -205,7 +205,12 @@ function LandingPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleCopy(value) + toast.success("Copied text successfully!")}
|
onClick={() => handleCopy(value) + toast("Copied text successfully!", {
|
||||||
|
action: {
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{copied === value ? (
|
{copied === value ? (
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
@@ -220,7 +225,7 @@ function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Closing Remarks */}
|
{/* Closing Remarks */}
|
||||||
<div className="xs:p-8 lg:p-12 flex flex-col xs:text-4xl leading-tight border-t font-bold tracking-tighter text-primary gap-8 hover:bg-muted dark:hover:bg-muted/20">
|
<div className="xs:p-8 lg:p-12 flex flex-col xs:text-4xl leading-tight border-t font-bold tracking-tighter text-primary dark:text-white gap-8 hover:bg-muted dark:hover:bg-muted/20">
|
||||||
<div>
|
<div>
|
||||||
Ready to supercharge? {<br />} Start by leveraging your limits.
|
Ready to supercharge? {<br />} Start by leveraging your limits.
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +233,7 @@ function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LandingPage;
|
export default LandingPage;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Navigate, Outlet } from 'react-router-dom'
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useEffect } from 'react'
|
import { getRoleHomePath } from '../utils/roleRedirect.util'
|
||||||
|
|
||||||
export default function PublicRoute() {
|
export default function PublicRoute() {
|
||||||
const { user, loading } = useAuth() // ← just loading and user
|
const { user, loading } = useAuth() // ← just loading and user
|
||||||
@@ -8,12 +8,7 @@ export default function PublicRoute() {
|
|||||||
if (loading) return null // ← only block on initial cold load
|
if (loading) return null // ← only block on initial cold load
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
switch (user.acc_type) {
|
return <Navigate to={getRoleHomePath(user)} replace />
|
||||||
case 'admin': return <Navigate to="/admin" replace />
|
|
||||||
case 'user': return <Navigate to={user.needs_intro ? '/intro' : '/dashboard'} replace />
|
|
||||||
case 'staff': return <Navigate to="/staff" replace />
|
|
||||||
default: return <Navigate to="/dashboard" replace />
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// RequirePasswordChange.jsx
|
// RequirePasswordChange.jsx
|
||||||
import { Navigate, Outlet } from 'react-router-dom'
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { getRoleHomePath } from '../utils/roleRedirect.util'
|
||||||
|
|
||||||
export default function RequirePasswordChange() {
|
export default function RequirePasswordChange() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -12,12 +13,7 @@ export default function RequirePasswordChange() {
|
|||||||
|
|
||||||
// User is logged in but doesn't need to change password → send to dashboard
|
// User is logged in but doesn't need to change password → send to dashboard
|
||||||
if (!user.must_change_password) {
|
if (!user.must_change_password) {
|
||||||
switch (user.acc_type) {
|
return <Navigate to={getRoleHomePath(user)} replace />
|
||||||
case 'admin': return <Navigate to="/admin" replace />
|
|
||||||
case 'staff': return <Navigate to="/staff" replace />
|
|
||||||
case 'client': return <Navigate to="/client" replace />
|
|
||||||
default: return <Navigate to="/" replace />
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is logged in AND must change password → allow through
|
// User is logged in AND must change password → allow through
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// utils/roleRedirect.util.js
|
||||||
|
// Single source of truth for "where does this account type land after auth."
|
||||||
|
// acc_type is only ever 'admin' | 'staff' | 'user' (see users.mdl.js) — there is
|
||||||
|
// no 'client' value, despite some older call sites checking for one.
|
||||||
|
export function getRoleHomePath(user) {
|
||||||
|
switch (user?.acc_type) {
|
||||||
|
case 'admin': return '/admin'
|
||||||
|
case 'staff': return '/staff'
|
||||||
|
case 'user': return user.needs_intro ? '/intro' : '/dashboard'
|
||||||
|
default: return '/dashboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user