add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
@@ -185,7 +185,7 @@ function UserCard({ entry, onOpen }) {
<button
type="button"
onClick={() => onOpen(entry)}
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="bg-background w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-accent/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
@@ -325,7 +325,7 @@ export default function CourseReadingProgressList({ courseId }) {
}
return (
<>
<div className="space-y-4">
{/* ── Summary strip ── */}
<div className="flex items-center gap-4 flex-wrap text-sm">
<span className="flex items-center gap-1.5">
@@ -350,7 +350,7 @@ export default function CourseReadingProgressList({ courseId }) {
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
maxLength={50}
className="pl-9 pr-16"
className="bg-background pl-9 pr-16"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{search.length}/50
@@ -382,6 +382,6 @@ export default function CourseReadingProgressList({ courseId }) {
entry={dialogEntry}
courseId={courseId}
/>
</>
</div>
);
}
@@ -1,5 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -119,6 +120,16 @@ export default function CoursesTable() {
onArchive={(c) => archiveCourse(c?.course_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
onImpactCheck={async () => {
const { data } = await api.get(
`/admin/courses/${archiveTarget?.course_id}/archive-impact`
);
const { activeCount, totalCount } = data?.data ?? {};
return [
{ label: "student(s) are currently taking this course", count: activeCount ?? 0 },
{ label: "student(s) have progress in this course", count: totalCount ?? 0 },
];
}}
/>
{/* ── Bulk archive ── */}
@@ -1,5 +1,6 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -128,6 +129,16 @@ export default function UnitsTable({ courseId }) {
onArchive={(c) => archiveUnit(courseId, c?.unit_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
onImpactCheck={async () => {
const { data } = await api.get(
`/admin/courses/${courseId}/units/${archiveTarget?.unit_id}/archive-impact`
);
const { completionCount, progressCount } = data?.data ?? {};
return [
{ label: "student(s) have completed this unit", count: completionCount ?? 0 },
{ label: "student(s) have reading progress in this unit", count: progressCount ?? 0 },
];
}}
/>
{/* ── Bulk archive ── */}
@@ -19,13 +19,23 @@ import { cn } from "@/lib/utils";
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
* assignments are already in selectedIds, so no race condition).
* false in AddPlan (always bundle all on first load).
* currentPlanId — plan being edited (undefined in AddPlan). A course already
* assigned to a DIFFERENT plan is flagged as a conflict, since
* plan_courses.course_id is UNIQUE — a course belongs to at
* most one plan, and reassigning it here silently steals it
* away from that plan on save.
* onConflictsChange — (count: number) => void. Called whenever the number of
* currently-SELECTED courses that conflict with another plan
* changes, so the parent can block submission until resolved.
*
* Flow:
* • Shows "Bundle all?" question with two buttons.
* • "Yes, include all" → selects every course in the tier, hides picker.
* • "No, choose specific" → opens a Popover with Command+Search+Checkboxes.
* • Selected courses already owned by another plan are called out, and the
* parent is expected to disable submission until they're unchecked.
*/
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false }) {
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false);
const [bundleAll, setBundleAll] = useState(true);
@@ -75,6 +85,29 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
);
}, [courses, search]);
// Courses already owned by a DIFFERENT plan — selecting them here will move them.
const isConflict = (course) =>
course.assigned_plan && String(course.assigned_plan.plan_id) !== String(currentPlanId ?? "");
// Only courses actually SELECTED matter — unchecking a conflicting course clears it.
const conflicts = useMemo(
() => courses.filter((c) => isConflict(c) && selectedIds.has(String(c.course_id))),
[courses, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps
);
const conflictsByPlan = useMemo(() => {
const map = new Map();
conflicts.forEach((c) => {
const label = c.assigned_plan.label;
map.set(label, (map.get(label) ?? 0) + 1);
});
return [...map.entries()];
}, [conflicts]);
useEffect(() => {
onConflictsChange?.(conflicts.length);
}, [conflicts.length]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -147,6 +180,26 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
</p>
)}
{/* ── Already-assigned-elsewhere warning ──────────────────────── */}
{!loading && conflicts.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">
{conflicts.length} course{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
</p>
<p>
A course can only belong to one plan at a time. Assigning {conflicts.length === 1 ? "it" : "them"} here will move {conflicts.length === 1 ? "it" : "them"} out of{" "}
{conflictsByPlan.map(([label, count], i) => (
<span key={label}>
<span className="font-medium">{label}</span> ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
</span>
))}. Uncheck them below if that's not what you want.
</p>
</div>
</div>
)}
{/* ── No courses in tier ───────────────────────────────────────── */}
{!loading && total === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
@@ -190,8 +243,9 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
const id = String(course.course_id);
const checked = selectedIds.has(id);
const conflict = isConflict(course);
return (
<CommandItem
key={id}
@@ -212,6 +266,12 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
{course.description}
</span>
)}
{conflict && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 w-fit mt-0.5">
<AlertTriangle className="size-3" />
In "{course.assigned_plan.label}"
</span>
)}
</div>
</CommandItem>
);
@@ -0,0 +1,61 @@
import { useState } from "react";
import { ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
export function CurrencyPicker({ value, currencies, onValueChange }) {
const [open, setOpen] = useState(false);
const selected = currencies.find((c) => c.code === value);
const label = selected ? `${selected.code} — ${selected.name}` : "Select currency";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="truncate">{label}</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
>
<Command>
<CommandInput placeholder="Search currency…" />
<ScrollArea className="h-64">
<CommandList className="max-h-none">
<CommandEmpty>No currency found.</CommandEmpty>
<CommandGroup>
{currencies.map((c) => (
<CommandItem
key={c.code}
value={`${c.code} ${c.name}`}
data-checked={value === c.code}
onSelect={() => {
onValueChange(c.code);
setOpen(false);
}}
>
{c.code} — {c.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen, ClipboardList } from "lucide-react";
import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
return [
@@ -8,19 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{
key: "view_units",
label: "View Units",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewUnits(row),
separator: true
separator: true,
},
{
key: "create_assessment",
label: "Create Assessment",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !!row.assessment_id,
},
{
key: "view_assessment",
@@ -28,7 +30,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewAssessment(row),
separator: true,
hidden: (row) => !row.assessment_id,
},
{
key: "modify_assessment",
@@ -36,6 +38,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !row.assessment_id,
},
{
key: "archive",
@@ -43,7 +46,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true
separator: true,
},
]
];
}
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList } from "lucide-react";
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
return [
@@ -20,7 +20,16 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <BookCheck className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewLessons(row),
separator: true
separator: true,
},
{
key: "create_quiz",
label: "Create Quiz",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !!(row.quiz_id || row.quiz),
separator: true,
},
{
key: "view_quiz",
@@ -28,6 +37,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
separator: true,
},
{
@@ -36,6 +46,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
},
{
key: "archive",
@@ -43,7 +54,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true
separator: true,
},
]
];
}
@@ -20,10 +20,11 @@ const STATUS_BADGE = {
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
const cellOverrides = {
status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
user_full_name: (info) => (
<span className="text-sm font-medium">{info.getValue() ?? "—"}</span>
),
"user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span>
),
amount: (info) => {
const row = info.row.original;
@@ -33,6 +34,11 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
</span>
);
},
status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
),
"plan.tier": (info) => {
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
@@ -42,9 +48,6 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
{fmtDateTime(info.getValue())}
</span>
),
"user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span>
),
};
const visibleAttributes = attributes.filter((a) => !a.hidden);
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, ShelvingUnit, CreditCard, Globe } from "lucide-react";
import { Eye, Archive } from "lucide-react";
export function buildRowActions({ navigate, onArchive }) {
return [
@@ -8,28 +8,6 @@ export function buildRowActions({ navigate, onArchive }) {
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
},
{
key: "edit",
label: "Edit Plan",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
},
{
key: "payment_policy",
label: "Payment Policy",
icon: <CreditCard className="h-3.5 w-3.5" />,
className: "text-blue-700 hover:text-blue-600",
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/payment-policy`),
separator: true,
},
{
key: "view_payments",
label: "View Payments",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
},
{
key: "archive",
label: "Archive",
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive, Layers, Globe } from "lucide-react";
import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
@@ -44,14 +44,6 @@ export function buildToolbarActions({
variant: "outline",
onClick: () => navigate("/admin/tiers/categories"),
},
{
key: "localized-prices",
type: "button",
label: "Localized Prices",
icon: <Globe className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/tiers/prices"),
},
{
key: "create",
type: "button",
+2 -2
View File
@@ -84,14 +84,14 @@ const AdminLayout = () => {
</div>
</div>
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<div id="main-body" className="bg-background flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<Outlet />
<Toaster position="bottom-right" richColors />
</div>
</AdminProvider>
{/* Footer sits outside AdminProvider intentionally */}
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground bg-background">
© Philproperties, 2026
</footer>
</TooltipProvider>
@@ -0,0 +1,172 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from "lucide-react";
import { House, Plus, Pencil, Trash2, Trophy, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminAchievementsProvider,
useAdminAchievements,
} from "@/contexts/AdminAchievementsContext";
function AchievementCard({ item, onEdit, onDelete }) {
const Icon = LucideIcons[item.icon] ?? Trophy;
return (
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{item.label}</p>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.key}</code>
<Badge variant="outline" className="text-[10px] capitalize">{item.type}</Badge>
{!item.is_active && <Badge variant="secondary">Inactive</Badge>}
{item.is_system && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-2.5 w-2.5" /> System
</Badge>
)}
</div>
{item.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
)}
{item.trigger && (
<p className="text-xs text-muted-foreground mt-0.5">
Trigger: <span className="font-medium text-foreground capitalize">{item.trigger}</span>
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
);
}
function AchievementsInner() {
const navigate = useNavigate();
const { achievements, loading, fetchAchievements, deleteAchievement } = useAdminAchievements();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => { fetchAchievements(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteAchievement(deleteTarget.achievement_definition_id);
setDeleting(false);
setDeleteTarget(null);
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Achievements - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Achievements" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Achievements</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Badges and milestones learners can earn across the platform.
</p>
</div>
<Button size="sm" onClick={() => navigate("/admin/achievements/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Achievement
</Button>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
<strong>System</strong> achievements are auto-granted by platform events (registration, course completion, etc.)
and cannot be deleted or have their key/type changed — everything else stays editable.
</p>
</div>
<Separator className="mb-5" />
{loading && !achievements.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !achievements.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No achievements found.</p>
) : (
<div className="space-y-3">
{achievements.map((item) => (
<AchievementCard
key={item.achievement_definition_id}
item={item}
onEdit={(a) => navigate(`/admin/achievements/${a.achievement_definition_id}/edit`)}
onDelete={(a) => setDeleteTarget(a)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Achievement</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
This action cannot be undone. Any courses referencing this achievement must be unassigned first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
export default function Achievements() {
return (
<AdminAchievementsProvider>
<AchievementsInner />
</AdminAchievementsProvider>
);
}
@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, X, Lock } from "lucide-react";
import { BADGE_ICON_OPTIONS } from "@/modules/admin/pages/tiers/EditTierCategory";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminAchievementsProvider,
useAdminAchievements,
} from "@/contexts/AdminAchievementsContext";
const TRIGGER_OPTIONS = [
{ value: "auth", label: "Auth (registration / login)" },
{ value: "tier", label: "Tier (subscription purchase)" },
{ value: "course", label: "Course (lessons / quizzes)" },
{ value: "profile", label: "Profile completion" },
{ value: "social", label: "Social (referrals / community)" },
{ value: "manual", label: "Manual (admin-granted only)" },
];
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function EditAchievementInner({ isAdd }) {
const navigate = useNavigate();
const { id } = useParams();
const { achievement, loading, fetchAchievement, createAchievement, updateAchievement } = useAdminAchievements();
const [key, setKey] = useState("");
const [type, setType] = useState("badge");
const [label, setLabel] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState(null);
const [trigger, setTrigger] = useState("manual");
const [isActive, setIsActive] = useState(true);
const [errors, setErrors] = useState({});
useEffect(() => {
if (!isAdd && id) fetchAchievement(id);
}, [id, isAdd]);
useEffect(() => {
if (achievement && !isAdd) {
setKey(achievement.key ?? "");
setType(achievement.type ?? "badge");
setLabel(achievement.label ?? "");
setDescription(achievement.description ?? "");
setIcon(achievement.icon ?? null);
setTrigger(achievement.trigger ?? "manual");
setIsActive(achievement.is_active ?? true);
}
}, [achievement, isAdd]);
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (isAdd && !key.trim()) e.key = "Key is required.";
if (isAdd && !/^[a-z0-9_]+$/.test(key)) e.key = "Key must be lowercase letters, numbers or underscores.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async () => {
if (!validate()) return;
const payload = {
type,
label: label.trim(),
description: description.trim() || null,
icon: icon || null,
trigger: trigger || null,
is_active: isActive,
};
if (isAdd) {
payload.key = key.trim();
const result = await createAchievement(payload);
if (result) navigate("/admin/achievements");
} else {
const result = await updateAchievement(id, payload);
if (result) navigate("/admin/achievements");
}
};
const isSystem = !isAdd && achievement?.is_system;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Achievement - STARR" : "Edit Achievement - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Achievements", to: "/admin/achievements" },
{ label: isAdd ? "Add Achievement" : (achievement?.label ?? "Edit") },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Achievement" : "Edit Achievement"}</h1>
<p className="text-sm text-muted-foreground">
{isAdd ? "Define a new badge or milestone learners can earn." : "Update this achievement's details."}
</p>
</div>
</div>
{isSystem && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> achievement — it's auto-granted by platform code that references
its key directly, so the key and type are locked. Label, description, icon, trigger and active state are still editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Achievement Details">
<div className="space-y-1.5">
<Label htmlFor="key">Key <span className="text-destructive">*</span></Label>
<Input
id="key"
value={key}
onChange={(e) => setKey(e.target.value.toLowerCase())}
placeholder="e.g. course_marathon"
disabled={!isAdd}
/>
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
<FieldError message={errors.key} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Course Marathon" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What does a learner do to earn this?" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType} disabled={isSystem}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="badge">Badge</SelectItem>
<SelectItem value="milestone">Milestone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Trigger</Label>
<Select value={trigger} onValueChange={setTrigger}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Informational only — doesn't wire up new automatic grants by itself.</p>
</div>
</div>
<div className="flex items-center gap-3">
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is_active">Active</Label>
</div>
</SectionCard>
<SectionCard title="Icon">
<p className="text-xs text-muted-foreground -mt-1">
Shown next to this achievement wherever it's displayed to learners.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setIcon(null)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!icon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
title="No icon"
>
<X className="size-3.5" />
</button>
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
const selected = icon === name;
return (
<button
key={name}
type="button"
title={name}
onClick={() => setIcon(name)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? "bg-secondary text-secondary-foreground border-foreground scale-105" : "border-border hover:border-muted-foreground"}`}
>
<Icon className="size-4" />
</button>
);
})}
</div>
{icon && (
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{icon}</span></p>
)}
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Achievement" : "Save Changes"}
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
function Wrapper({ isAdd }) {
return (
<AdminAchievementsProvider>
<EditAchievementInner isAdd={isAdd} />
</AdminAchievementsProvider>
);
}
export function AddAchievement() { return <Wrapper isAdd={true} />; }
export function EditAchievement() { return <Wrapper isAdd={false} />; }
@@ -1,14 +1,19 @@
// modules/admin/pages/advertisements/AddAdvertisement.jsx
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import {
House, Plus, Trash2, ImagePlus, MapPin, FileText,
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
} from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -16,15 +21,19 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +48,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -53,6 +63,18 @@ const schema = z.object({
}
});
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
const ALL_STEPS = [
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose the page and position this ad will appear in." },
{ id: "content", label: "Content", icon: FileText, description: "Headline, description, and badge text for this placement." },
{ id: "image", label: "Image", icon: ImagePlus, description: "Choose an existing asset from Asset Management." },
{ id: "ctas", label: "CTAs", icon: Link2, description: `Up to ${MAX_CTAS} buttons shown on the placement.`, richOnly: true },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Optional start/end dates, manual ordering, and the on/off switch." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -60,24 +82,11 @@ function FieldError({ message }) {
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -85,6 +94,348 @@ const CTA_VARIANTS = [
{ value: "outline", label: "Outline" },
];
// ─── Stepper header ─────────────────────────────────────────────────────────
function Stepper({ steps, stepIndex }) {
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const Icon = s.icon;
const isActive = stepIndex === i;
const isDone = stepIndex > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors shrink-0",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
stepIndex > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
);
}
// ─── Step: Placement ────────────────────────────────────────────────────────
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, errors, format, isBanner, watch }) {
return (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Page</Label>
<Select
value={selectedPage ?? undefined}
onValueChange={(v) => {
setSelectedPage(v);
setValue("placement", "", { shouldValidate: false });
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{AD_PAGES.map((p) => (
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Position</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true })}
disabled={!selectedPage}
>
<SelectTrigger>
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
</SelectTrigger>
<SelectContent>
{positionOptions.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
</div>
{placement && (
<div>
<p className="text-xs text-muted-foreground mb-2">
Preview — the highlighted area is roughly where this ad will appear.
</p>
<PlacementSkeleton placement={placement} />
<p className="text-xs text-muted-foreground mt-2">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
</div>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</div>
);
}
// ─── Step: Content ──────────────────────────────────────────────────────────
function StepContent({ register, errors, showRichContent, description, format }) {
if (!showRichContent) {
return (
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
);
}
return (
<div className="space-y-5">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{format === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</div>
);
}
// ─── Step: Image ────────────────────────────────────────────────────────────
function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
return selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
);
}
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
function StepCtas({ ctaFields, register, errors, watch, setValue, appendCta, removeCta }) {
return (
<div className="space-y-3">
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</div>
);
}
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
return (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</div>
);
}
// ─── Step: Review ───────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[110px] shrink-0">{label}</span>
<span className="text-foreground text-right break-words">{value}</span>
</div>
);
}
function StepReview({ data, selectedAsset, imageUrl }) {
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
return (
<div className="space-y-4">
<div className="border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Placement</span>
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
</div>
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<SummaryRow label="Position" value={placementMeta?.slotLabel} />
<SummaryRow label="Size" value={data.size} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Content</p>
<SummaryRow label="Badge" value={data.badge_label} />
<SummaryRow label="Headline" value={data.headline} />
<SummaryRow label="Description" value={data.description} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Image</p>
{selectedAsset ? (
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="h-24 rounded border object-cover"
/>
) : (
<p className="text-sm text-muted-foreground">No image selected.</p>
)}
</div>
{ctas.length > 0 && (
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Calls to action</p>
{ctas.map((c, i) => (
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
))}
</div>
)}
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date} />
<SummaryRow label="End date" value={data.end_date} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Active" value={data.is_active ? "Yes" : "No"} />
</div>
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddAdvertisement() {
@@ -94,18 +445,23 @@ export default function AddAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
const [step, setStep] = useState(0);
const {
register,
handleSubmit,
control,
trigger,
getValues,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -121,10 +477,19 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const steps = useMemo(
() => ALL_STEPS.filter((s) => !s.richOnly || showRichContent),
[showRichContent]
);
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -132,19 +497,33 @@ export default function AddAdvertisement() {
{ label: "New" },
];
const onSubmit = async (values) => {
// Validate only the current step's fields before advancing
const handleNext = async () => {
let fields = [];
if (current.id === "placement") fields = ["placement"];
else if (current.id === "content") fields = showRichContent ? ["headline", "description", "badge_label"] : ["headline"];
else if (current.id === "ctas") fields = ["ctas"];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
};
const handleBack = () => setStep((s) => Math.max(s - 1, 0));
// Called manually — no <form> tag so Enter/click on earlier steps can't accidentally submit
const handleCreate = handleSubmit(async (values) => {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
createdBy: user?.user_id ?? null,
};
const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements");
};
});
return (
<section className="bg-muted h-full">
@@ -153,204 +532,87 @@ export default function AddAdvertisement() {
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
<div className="w-full max-w-2xl pb-10 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground">Create a banner, popup, or hero placement.</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Stepper steps={steps} stepIndex={stepIndex} />
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
</div>
<div className="rounded-lg border bg-card p-6 min-h-[360px]">
<div className="space-y-0.5 pb-4 mb-1 border-b">
<h2 className="text-sm font-semibold">{current.label}</h2>
<p className="text-xs text-muted-foreground">{current.description}</p>
</div>
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</SectionCard>
{showRichContent && (
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</SectionCard>
{current.id === "placement" && (
<StepPlacement
selectedPage={selectedPage}
setSelectedPage={setSelectedPage}
placement={placement}
setValue={setValue}
positionOptions={positionOptions}
errors={errors}
format={format}
isBanner={isBanner}
watch={watch}
/>
)}
{!showRichContent && (
<SectionCard title="Content" description="Optional headline for this placement.">
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
</SectionCard>
{current.id === "content" && (
<StepContent
register={register}
errors={errors}
showRichContent={showRichContent}
description={description}
format={format}
/>
)}
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
)}
</SectionCard>
{showRichContent && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</SectionCard>
{current.id === "image" && (
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
)}
{current.id === "ctas" && (
<StepCtas
ctaFields={ctaFields}
register={register}
errors={errors}
watch={watch}
setValue={setValue}
appendCta={appendCta}
removeCta={removeCta}
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
)}
{current.id === "review" && (
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
)}
</div>
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
</SectionCard>
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
disabled={loading}
>
<ChevronLeft className="size-4" />
{stepIndex === 0 ? "Cancel" : "Back"}
</Button>
<SectionCard title="Display" description="Manual ordering and on/off switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{stepIndex === steps.length - 1 ? (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create advertisement
</Button>
</div>
</form>
) : (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="size-4" />
</Button>
)}
</div>
</div>
</div>
@@ -358,11 +620,12 @@ export default function AddAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
</section>
);
}
}
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -17,24 +18,27 @@ import {
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
fetchAdvertisements({ page: 1, limit: 24, filters });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search]);
}, [typeFilter, placementFilter, statusFilter, search]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -82,7 +86,7 @@ export default function AdvertisementList() {
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
@@ -93,8 +97,20 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
<Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger className="w-[220px] bg-background">
<SelectValue placeholder="All placements" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All placements</SelectItem>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel} — {p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
@@ -109,7 +125,7 @@ export default function AdvertisementList() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8"
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -164,11 +180,12 @@ function StatCard({ label, value, tone = "default" }) {
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const placementMeta = PLACEMENT_MAP[ad.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
const previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
@@ -178,13 +195,13 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<button
type="button"
onClick={onView}
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
aria-label="View advertisement details"
>
{previewSrc ? (
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-7 text-muted-foreground" />
<Megaphone className="size-7" />
)}
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
@@ -199,6 +216,11 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
{placementMeta ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
) : (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">Unassigned placement</p>
)}
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
</button>
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,12 +20,13 @@ import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +41,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -86,6 +89,7 @@ const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -103,6 +107,15 @@ export default function EditAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
// Gates the form's first paint until the fetched advertisement has been
// applied via reset() + setSelectedPage(). Without this, the Page/Position
// selects briefly mount with their empty defaultValues (no page selected,
// no position options yet) before the fetch resolves — that first paint is
// enough for the position <Select> to lose track of the eventual value,
// leaving it visually unselected even after reset() runs.
const [ready, setReady] = useState(false);
const {
register,
@@ -115,7 +128,7 @@ export default function EditAdvertisement() {
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -131,10 +144,12 @@ export default function EditAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -149,10 +164,17 @@ export default function EditAdvertisement() {
const ad = res?.data?.data ?? null;
if (!ad) return;
if (ad.image) setSelectedAsset(ad.image);
if (ad.image) {
setSelectedAsset(ad.image);
// ad.image already carries a stream_token for S3-backed assets
// (minted server-side in controllers/admin/advertisements.controller.js)
// — no separate token round-trip needed.
setImagePreviewUrl(resolveAssetSrc(ad.image));
}
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
reset({
type: ad.type ?? undefined,
placement: ad.placement ?? undefined,
badge_label: ad.badge_label ?? "",
headline: ad.headline ?? "",
description: ad.description ?? "",
@@ -168,6 +190,8 @@ export default function EditAdvertisement() {
is_active: ad.is_active ?? true,
size: ad.size ?? null,
});
setReady(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advertisementId]);
@@ -180,7 +204,7 @@ export default function EditAdvertisement() {
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
updatedBy: user?.user_id ?? null,
};
@@ -188,6 +212,16 @@ export default function EditAdvertisement() {
if (res) navigate("/admin/advertisements");
};
if (!ready) {
return (
<section className="bg-muted h-full">
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
</section>
);
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
@@ -202,26 +236,52 @@ export default function EditAdvertisement() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Page</Label>
<Select
value={selectedPage ?? undefined}
onValueChange={(v) => {
setSelectedPage(v);
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{AD_PAGES.map((p) => (
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Position</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
disabled={!selectedPage}
>
<SelectTrigger>
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
</SelectTrigger>
<SelectContent>
{positionOptions.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
</div>
{format && (
<p className="text-xs text-muted-foreground">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
@@ -255,7 +315,7 @@ export default function EditAdvertisement() {
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
{format === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
@@ -283,7 +343,7 @@ export default function EditAdvertisement() {
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
src={imagePreviewUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
@@ -400,8 +460,9 @@ export default function EditAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -12,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -85,11 +87,12 @@ export default function ViewAdvertisement() {
);
}
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const placementMeta = PLACEMENT_MAP[advertisement.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = advertisement.image?.thumbnail_url || advertisement.image?.file_url || advertisement.image_url || null;
const previewSrc = resolveAssetSrc(advertisement.image) || advertisement.image_url || null;
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return (
@@ -111,7 +114,7 @@ export default function ViewAdvertisement() {
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="gap-1">
<TypeIcon className="size-3" />
{typeMeta.label ?? advertisement.type}
@@ -119,6 +122,15 @@ export default function ViewAdvertisement() {
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
{statusMeta.label ?? advertisement.status}
</Badge>
{placementMeta ? (
<Badge variant="outline">
{placementMeta.pageLabel} — {placementMeta.slotLabel}
</Badge>
) : (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:text-amber-400">
Unassigned placement
</Badge>
)}
</div>
</div>
</div>
@@ -7,11 +7,11 @@ import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
@@ -49,6 +49,14 @@ export default function ViewAudioAsset() {
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token/thumbnail_url for S3 assets —
// only fall back to a token request if it's somehow missing (expired
// cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
if (selectedAsset.thumbnail_url) setThumbnailUrl(selectedAsset.thumbnail_url);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => {
const { token, thumbnail_url } = data?.data ?? {};
@@ -59,11 +67,7 @@ export default function ViewAudioAsset() {
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
@@ -43,10 +43,15 @@ export default function ViewDocumentAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
@@ -7,10 +7,10 @@ import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
@@ -41,19 +41,20 @@ export default function ViewImageAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
@@ -7,10 +7,10 @@ import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
@@ -51,19 +51,20 @@ export default function ViewVideoAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
+421 -333
View File
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, Plus, Trash2, BadgeCheck, Trophy, Check, ChevronsUpDown, X, ImagePlus, Palette } from "lucide-react";
import {
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Trophy,
Check, ChevronsUpDown, X, ImagePlus, Palette, BookOpen,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -17,28 +20,16 @@ import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
@@ -52,9 +43,16 @@ const schema = z.object({
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
achievement_keys: z.array(z.string()).max(3).default([]),
achievement_keys: z.array(z.string()).max(1).default([]),
});
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Rewards", description: "Badge & achievements" },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -76,6 +74,56 @@ function SectionCard({ title, description, children }) {
);
}
function StepIndicator({ steps, current, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
className="flex flex-col items-center gap-1.5 shrink-0 group"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddCourse() {
@@ -83,6 +131,7 @@ export default function AddCourse() {
const { createCourse, loading } = useCourses();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -90,7 +139,6 @@ export default function AddCourse() {
.catch(() => {});
}, []);
// ─── Badge config state ─────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
@@ -100,6 +148,7 @@ export default function AddCourse() {
const {
register,
handleSubmit,
trigger,
control,
setValue,
formState: { errors },
@@ -120,19 +169,34 @@ export default function AddCourse() {
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => {
api.get("/admin/achievements")
.then(({ data }) => setAchievementRegistry((data.data ?? []).filter((a) => a.is_active)))
.catch(() => setAchievementRegistry([]));
}, []);
const toggleAchievement = (key) => {
if (currentAchKeys.includes(key)) {
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
} else if (currentAchKeys.length < 3) {
setValue("achievement_keys", [...currentAchKeys, key], { shouldDirty: true });
} else {
setValue("achievement_keys", [key], { shouldDirty: true });
}
};
const handleNext = async () => {
if (currentStep === 0) {
const valid = await trigger(["title", "subscription", "objectives"]);
if (!valid) return;
}
setCurrentStep((s) => s + 1);
};
const onSubmit = async (values) => {
const payload = {
...values,
@@ -151,352 +215,376 @@ export default function AddCourse() {
};
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title="Add Course - STARR" description="Create a new training course." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Add Course</h1>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<BookOpen className="h-5 w-5 text-muted-foreground" />
Add Course
</h1>
<p className="text-sm text-muted-foreground">Create a new training course.</p>
</div>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-2xl mx-auto">
<StepIndicator steps={STEPS} current={currentStep} onStepClick={setCurrentStep} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* ── Basic Info ── */}
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
{/* ── Objectives ── */}
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<div className="space-y-2">
{objectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`objectives.${index}.text`)}
/>
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* ── Step 0: Basic Info ── */}
{currentStep === 0 && (
<>
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</div>
</SectionCard>
{/* ── Rewards ── */}
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* ── Completion Badge ── */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
{/* Metadata */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
{/* Color picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
<div className="space-y-2">
{objectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`objectives.${index}.text`)}
/>
))}
</div>
</div>
{/* Image picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
<Trash2 className="h-4 w-4" />
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
</div>
))}
{/* ── Achievements ── */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length}/3 selected</span>
</div>
{/* Selected badges */}
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
{/* Popover picker */}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
disabled={currentAchKeys.length >= 3}
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? `${currentAchKeys.length} selected — add more`
: "Select achievements"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
const disabled = !checked && currentAchKeys.length >= 3;
return (
<CommandItem
key={ach.key}
value={ach.label}
disabled={disabled}
onSelect={() => !disabled && toggleAchievement(ach.key)}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
</div>
</SectionCard>
</>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
{/* ── Step 1: Rewards ── */}
{currentStep === 1 && (
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* Completion Badge */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
))}
</div>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length > 0 ? "1 selected" : "none selected"}</span>
</div>
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = achievementRegistry.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? "Change achievement"
: "Select achievement"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{achievementRegistry.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
return (
<CommandItem
key={ach.key}
value={ach.label}
onSelect={() => {
toggleAchievement(ach.key);
setAchOpen(false);
}}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
)}
{/* Asset picker (always mounted) */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
{/* ── Step navigation ── */}
<div className="flex items-center justify-between pt-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={loading}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
</Button>
)}
</div>
</form>
</div>
</div>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
</section>
</div>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -204,10 +205,12 @@ export default function CourseAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
const {
fetchAssessment, createAssessment, updateAssessment,
createAssessmentQuestion, updateAssessmentQuestion,
course, assessment, loading,
createAssessment, updateAssessment,
bulkSyncAssessmentQuestions,
course, loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const { user } = useAuth();
const [initializing, setInitializing] = useState(true);
@@ -236,30 +239,39 @@ export default function CourseAssessment() {
const navContainerRef = useRef(null);
const headerRef = useRef(null);
// ── Fetch ──────────────────────────────────────────────────────────────────
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
useEffect(() => {
(async () => {
await fetchAssessment(courseId);
setInitializing(false);
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
const result = data?.data?.data ?? null;
setLocalAssessment(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
}
} finally {
setInitializing(false);
}
})();
}, [courseId]);
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!assessment) return;
const t = assessment.title ?? "";
const ps = assessment.passing_score ?? 70;
const tl = assessment.time_limit_minutes ?? "";
const ir = assessment.is_required === true || assessment.is_required === 1;
const mq = assessment.max_questions ?? "";
const ma = assessment.max_attempts ?? 3;
const ch = assessment.cooldown_hours ?? 24;
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
if (!localAssessment) return;
const t = localAssessment.title ?? "";
const ps = localAssessment.passing_score ?? 70;
const tl = localAssessment.time_limit_minutes ?? "";
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
const mq = localAssessment.max_questions ?? "";
const ma = localAssessment.max_attempts ?? 3;
const ch = localAssessment.cooldown_hours ?? 24;
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
}, [assessment]);
}, [localAssessment]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
useEffect(() => {
@@ -379,7 +391,7 @@ export default function CourseAssessment() {
return;
}
const assessmentId = assessment?.assessment_id;
const assessmentId = localAssessment?.assessment_id;
const meta = {
title: title || "Course Assessment",
passing_score: passingScore,
@@ -420,18 +432,12 @@ export default function CourseAssessment() {
const res = await createAssessment(courseId, meta);
id = res?.data?.data?.data?.assessment_id;
if (!id) return;
setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
} else {
await updateAssessment(courseId, id, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
await updateAssessmentQuestion(courseId, id, q.question_id, q);
} else {
await createAssessmentQuestion(courseId, id, q);
}
}
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id);
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
};
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,10 @@ import {
CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp,
} from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
@@ -338,27 +340,35 @@ export default function ViewAssessment() {
const { courseId } = useParams();
const {
fetchAssessment, assessment,
fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions,
loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
fetchAssessment(courseId);
(async () => {
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
setLocalAssessment(data?.data?.data ?? null);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
}
}
})();
}, [courseId]);
// Lazy-load completions/sessions the first time each tab is opened
const loadedRef = { completions: false, sessions: false };
useEffect(() => {
if (!assessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, assessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, assessment.assessment_id);
}, [activeTab, assessment?.assessment_id]);
if (!localAssessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, localAssessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, localAssessment.assessment_id);
}, [activeTab, localAssessment?.assessment_id]);
const questions = assessment?.questions ?? [];
const questions = localAssessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
@@ -380,7 +390,7 @@ export default function ViewAssessment() {
<ClipboardList className="h-5 w-5 text-muted-foreground" />
View Assessment
</h1>
{assessment && (
{localAssessment && (
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
@@ -393,7 +403,7 @@ export default function ViewAssessment() {
</div>
{/* Tabs */}
{assessment && (
{localAssessment && (
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
@@ -416,9 +426,9 @@ export default function ViewAssessment() {
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !assessment ? (
{loading && !localAssessment ? (
<LoadingSkeleton />
) : !assessment ? (
) : !localAssessment ? (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
@@ -431,23 +441,23 @@ export default function ViewAssessment() {
<>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Title">{localAssessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Required">
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
{assessment.is_required ? "Required" : "Optional"}
<Badge variant={localAssessment.is_required ? "default" : "secondary"} className="mt-0.5">
{localAssessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Passing Score">{localAssessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{assessment.time_limit_minutes ? `${assessment.time_limit_minutes} mins` : "No limit"}
{localAssessment.time_limit_minutes ? `${localAssessment.time_limit_minutes} mins` : "No limit"}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt">
{assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
{localAssessment.max_questions ? `${localAssessment.max_questions} (random)` : `All (${questions.length})`}
</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
<InfoRow label="Max Failed Attempts">{assessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{assessment.cooldown_hours ?? 24}h</InfoRow>
<InfoRow label="Max Failed Attempts">{localAssessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{localAssessment.cooldown_hours ?? 24}h</InfoRow>
</div>
</SectionCard>
+297 -260
View File
@@ -11,7 +11,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -66,36 +65,261 @@ function LoadingSkeleton() {
);
}
// ─── Tab: Course Details ───────────────────────────────────────────────────────
function CourseDetailsTab({ course, loading, instructors, achievementKeys, achievementRegistry, badgeImageUrl }) {
const { fmtDateTime } = useDateFormat();
if (loading && !course) return <LoadingSkeleton />;
if (!course) return <div className="text-sm text-muted-foreground">Course not found.</div>;
return (
<div className="space-y-5">
<SectionCard icon={BookOpen} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Title">{course.title}</InfoRow>
{course.description && (
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{course.description}
</span>
</InfoRow>
)}
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Course Code">
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
</InfoRow>
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
</div>
</div>
</SectionCard>
<SectionCard icon={Tag} title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Level">
{course.level ? (
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
{course.level}
</Badge>
) : null}
</InfoRow>
<InfoRow label="Subscription">
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
{course.subscription ?? "free"}
</Badge>
</InfoRow>
</div>
</SectionCard>
<SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration">
{course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
</InfoRow>
<InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
{course.unitCount ?? course.units?.length ?? 0}
</div>
</InfoRow>
<InfoRow label="Lessons">
<div className="flex items-center gap-1.5 mt-0.5">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{course.lessonCount ?? 0}
</div>
</InfoRow>
</div>
</SectionCard>
{course.objectives?.length > 0 && (
<SectionCard icon={ListChecks} title="Learning Objectives">
<ul className="space-y-2">
{course.objectives.map((obj, i) => (
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
{obj.text}
</li>
))}
</ul>
</SectionCard>
)}
<SectionCard icon={Users} title="Course Instructors">
{instructors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
) : (
<ul className="space-y-3">
{instructors.map((inst, i) => {
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
const email = inst.user?.email ?? null;
return (
<li key={inst.id ?? i} className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
{(fullName || email) && (
<p className="text-xs text-muted-foreground truncate">
{fullName ?? email}
</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
#{i + 1}
</Badge>
</li>
);
})}
</ul>
)}
</SectionCard>
<SectionCard icon={Award} title="Rewards">
<div className="space-y-5">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={course.title}
level={course.level}
color={course.badge_color ?? "purple"}
imageUrl={badgeImageUrl}
/>
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
Achievements
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
</p>
{achievementKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
) : (
<ul className="space-y-2">
{achievementKeys.map((key) => {
const ach = achievementRegistry.find((a) => a.key === key);
return (
<li key={key} className="flex items-start gap-2.5 text-sm">
<div className="mt-0.5 shrink-0">
{ach?.type === "badge"
? <Trophy className="h-4 w-4 text-amber-500" />
: <BadgeCheck className="h-4 w-4 text-primary" />
}
</div>
<div className="min-w-0">
<span className="font-medium">{ach?.label ?? key}</span>
{ach?.description && (
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
{ach?.type ?? "badge"}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</div>
</SectionCard>
{course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites">
<ul className="space-y-2">
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
</li>
))}
</ul>
</SectionCard>
)}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
? `${course.assessment.time_limit_minutes} mins`
: "No limit"}
</InfoRow>
</div>
</SectionCard>
)}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
);
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Course Details", icon: BookOpen },
{ key: "progress", label: "Reading Progress", icon: BarChart2 },
];
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat();
const [activeTab, setActiveTab] = useState("details");
const [instructors, setInstructors] = useState([]);
const [achievementKeys, setAchievementKeys] = useState([]);
const [achievementRegistry, setAchievementRegistry] = useState([]);
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
useEffect(() => {
fetchCourse(courseId);
// Instructors
api.get(`/admin/courses/${courseId}/instructors`)
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
.catch(() => {});
// Achievements
api.get(`/admin/courses/${courseId}/achievements`)
.then(({ data }) => {
const rows = data?.data?.data ?? [];
setAchievementKeys(rows.map((r) => r.achievement_key));
})
.catch(() => {});
api.get("/admin/achievements")
.then(({ data }) => setAchievementRegistry(data.data ?? []))
.catch(() => {});
}, [courseId]);
// Fresh stream token for the badge image once course loads
useEffect(() => {
if (!course?.badge_asset_id) {
setBadgeImageUrl(course?.badge_image_url ?? null);
@@ -112,269 +336,82 @@ export default function ViewCourse() {
}, [course?.badge_asset_id, course?.badge_image_url]);
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
{/* ── Header ── */}
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Course Details</h1>
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<BookOpen className="h-5 w-5 text-muted-foreground" />
View Course
</h1>
{course && (
<p className="text-sm text-muted-foreground capitalize">
{course.course_code ? `${course.course_code} — ` : ""}{course.title}
</p>
)}
</div>
{activeTab === "details" && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Course
</Button>
)}
</div>
{loading && !course ? (
<LoadingSkeleton />
) : !course ? (
<div className="text-sm text-muted-foreground">Course not found.</div>
) : (
<div className="space-y-5">
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
{/* ── Basic Info ── */}
<SectionCard icon={BookOpen} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Title">{course.title}</InfoRow>
{course.description && (
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{course.description}
</span>
</InfoRow>
)}
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Course Code">
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
</InfoRow>
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard icon={Tag} title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Level">
{course.level ? (
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
{course.level}
</Badge>
) : null}
</InfoRow>
<InfoRow label="Subscription">
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
{course.subscription ?? "free"}
</Badge>
</InfoRow>
</div>
</SectionCard>
{/* ── Duration & Stats ── */}
<SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration">
{course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
</InfoRow>
<InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
{course.unitCount ?? course.units?.length ?? 0}
</div>
</InfoRow>
<InfoRow label="Lessons">
<div className="flex items-center gap-1.5 mt-0.5">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{course.lessonCount ?? 0}
</div>
</InfoRow>
</div>
</SectionCard>
{/* ── Objectives ── */}
{course.objectives?.length > 0 && (
<SectionCard icon={ListChecks} title="Learning Objectives">
<ul className="space-y-2">
{course.objectives.map((obj, i) => (
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
{obj.text}
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Instructors ── */}
<SectionCard icon={Users} title="Course Instructors">
{instructors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
) : (
<ul className="space-y-3">
{instructors.map((inst, i) => {
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
const email = inst.user?.email ?? null;
return (
<li key={inst.id ?? i} className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
{(fullName || email) && (
<p className="text-xs text-muted-foreground truncate">
{fullName ?? email}
</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
#{i + 1}
</Badge>
</li>
);
})}
</ul>
)}
</SectionCard>
{/* ── Rewards ── */}
<SectionCard icon={Award} title="Rewards">
<div className="space-y-5">
{/* Completion badge */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={course.title}
level={course.level}
color={course.badge_color ?? "purple"}
imageUrl={badgeImageUrl}
/>
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
Achievements
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
</p>
{achievementKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
) : (
<ul className="space-y-2">
{achievementKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<li key={key} className="flex items-start gap-2.5 text-sm">
<div className="mt-0.5 shrink-0">
{ach?.type === "badge"
? <Trophy className="h-4 w-4 text-amber-500" />
: <BadgeCheck className="h-4 w-4 text-primary" />
}
</div>
<div className="min-w-0">
<span className="font-medium">{ach?.label ?? key}</span>
{ach?.description && (
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
{ach?.type ?? "badge"}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</div>
</SectionCard>
{/* ── Prerequisites ── */}
{course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites">
<ul className="space-y-2">
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Assessment ── */}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
{/* <InfoRow label="Required">
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
{course.assessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow> */}
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
? `${course.assessment.time_limit_minutes} mins`
: "No limit"}
</InfoRow>
</div>
</SectionCard>
)}
{/* ── Reading Progress ── */}
<SectionCard icon={BarChart2} title="Reading Progress">
<CourseReadingProgressList courseId={courseId} />
</SectionCard>
{/* ── Audit ── */}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto pb-16">
{activeTab === "details" && (
<CourseDetailsTab
course={course}
loading={loading}
instructors={instructors}
achievementKeys={achievementKeys}
achievementRegistry={achievementRegistry}
badgeImageUrl={badgeImageUrl}
/>
)}
{activeTab === "progress" && (
<CourseReadingProgressList courseId={courseId} />
)}
</div>
</div>
</section>
</div>
);
}
@@ -54,7 +54,7 @@ export default function LessonsList() {
<div className="w-full space-y-6">
{/* ── Unit header ── */}
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="bg-card rounded-xl border p-6 space-y-4">
{/* Title + Status */}
<div>
<h6 className="text-xs tracking-widest mb-1">UNIT</h6>
@@ -70,21 +70,21 @@ export default function ViewLesson() {
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
<Clock className="h-3 w-3" /> Duration
</p>
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
</div>
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -196,10 +198,12 @@ export default function ModifyQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const {
fetchQuiz, createQuiz, updateQuiz,
createQuizQuestion, updateQuizQuestion,
course, unit, quiz, loading,
createQuiz, updateQuiz,
bulkSyncQuizQuestions,
course, unit, loading,
} = useCourses();
const [localQuiz, setLocalQuiz] = useState(null);
const { user } = useAuth();
const [initializing, setInitializing] = useState(true);
@@ -227,26 +231,36 @@ export default function ModifyQuiz() {
{ label: "Quiz" },
];
// ── Fetch ──────────────────────────────────────────────────────────────────
// ── Fetch — silently treat 404 as "no quiz yet" (create mode) ─────────────
useEffect(() => {
(async () => {
await fetchQuiz(courseId, unitId);
setInitializing(false);
try {
const { data } = await api.get(`/admin/courses/${courseId}/units/${unitId}/quiz`);
const result = data?.data?.data ?? null;
setLocalQuiz(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
}
// 404 → no quiz yet, stay in create mode with localQuiz = null
} finally {
setInitializing(false);
}
})();
}, [courseId, unitId]);
// ── Seed ──────────────────────────────────────────────────────────────────
// ── Seed form from fetched quiz ────────────────────────────────────────────
useEffect(() => {
if (!quiz) return;
const t = quiz.title ?? "";
const ps = quiz.passing_score ?? 70;
const ir = quiz.is_required === true || quiz.is_required === 1;
const mq = quiz.max_questions ?? "";
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
if (!localQuiz) return;
const t = localQuiz.title ?? "";
const ps = localQuiz.passing_score ?? 70;
const ir = localQuiz.is_required === true || localQuiz.is_required === 1;
const mq = localQuiz.max_questions ?? "";
const sq = localQuiz.shuffle_questions === true || localQuiz.shuffle_questions === 1;
const qs = (localQuiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
}, [quiz]);
}, [localQuiz]);
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
useEffect(() => {
@@ -366,7 +380,7 @@ export default function ModifyQuiz() {
return;
}
let quizId = quiz?.quiz_id;
let quizId = localQuiz?.quiz_id;
const meta = {
title: title || "Unit Quiz",
passing_score: passingScore,
@@ -381,18 +395,12 @@ export default function ModifyQuiz() {
const res = await createQuiz(courseId, unitId, meta);
quizId = res?.data?.data?.data?.quiz_id;
if (!quizId) return;
setLocalQuiz((prev) => ({ ...prev, quiz_id: quizId }));
} else {
await updateQuiz(courseId, unitId, quizId, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
await updateQuizQuestion(courseId, unitId, quizId, q.question_id, q);
} else {
await createQuizQuestion(courseId, unitId, quizId, q);
}
}
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
@@ -52,7 +52,7 @@ export default function UnitsList() {
<div className="w-full space-y-6">
{/* ── Course header ── */}
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="bg-card rounded-xl border p-6 space-y-4">
{/* Title + Status */}
<div>
<h6 className="text-xs tracking-widest mb-1">COURSE</h6>
@@ -0,0 +1,367 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import ReactMarkdown from "react-markdown";
import { ChevronRight, ChevronLeft, Check, Tags, FileText, Code2, ClipboardCheck, House, Eye, Send } from "lucide-react";
import { useAdminEmailTemplates, AdminEmailTemplateProvider } from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory } from "@/data/emailTemplateCategories.data";
import { markdownToHtml } from "@/utils/markdownToHtml.util";
import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Zod schema ───────────────────────────────────────────────────────────────
// body_markdown is what the admin actually authors — converted to html_body
// (the column services/email.service.js reads) right before submission.
const emailTemplateSchema = z.object({
category: z.enum(["announcement", "advertisement", "system", "other"]),
type: z.string().min(1, "Type is required").regex(/^[A-Z][A-Z0-9_]*$/, "Uppercase letters, numbers or underscores only, starting with a letter."),
label: z.string().min(1, "Label is required"),
subject: z.string().min(1, "Subject is required"),
body_markdown: z.string().min(1, "Body is required"),
});
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: "Category", icon: Tags, fields: ["category"] },
{ id: 1, label: "Details", icon: FileText, fields: ["type", "label"] },
{ id: 2, label: "Content", icon: Code2, fields: ["subject", "body_markdown"] },
{ id: 3, label: "Review", icon: ClipboardCheck, fields: [] },
];
const DEFAULT_VALUES = {
category: "",
type: "",
label: "",
subject: "",
body_markdown: "",
};
function Field({ label, required, error, children, hint }) {
return (
<div className="space-y-1.5">
<Label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{children}
{hint && !error && <p className="text-xs text-muted-foreground">{hint}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Step 1 — Category ────────────────────────────────────────────────────────
function StepCategory({ control, error }) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
What is this email for? This just helps organize templates in the list — it doesn't change how or when the email is sent.
</p>
<Controller
control={control}
name="category"
render={({ field }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const selected = field.value === cat.value;
return (
<button
key={cat.value}
type="button"
onClick={() => field.onChange(cat.value)}
className={cn(
"text-left rounded-lg border-2 p-4 transition-all flex items-start gap-3",
selected ? "border-foreground bg-muted" : "border-border hover:border-muted-foreground"
)}
>
<div className={cn("w-9 h-9 rounded-lg border flex items-center justify-center shrink-0", cat.badgeClass)}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold">{cat.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{cat.description}</p>
</div>
</button>
);
})}
</div>
)}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Step 2 — Details ─────────────────────────────────────────────────────────
function StepDetails({ register, errors, typeValue }) {
return (
<div className="space-y-4">
<Field
label="Type" required error={errors.type?.message}
hint="Uppercase, no spaces. This is the key your code passes to sendEmail({ type }) — cannot be changed after creation."
>
<Input
{...register("type", { setValueAs: (v) => v.toUpperCase() })}
placeholder="e.g. INVOICE_RECEIPT"
style={{ textTransform: "uppercase" }}
/>
</Field>
<Field label="Label" required error={errors.label?.message} hint="A friendly name shown in the admin list.">
<Input {...register("label")} placeholder="e.g. Invoice Receipt" />
</Field>
{typeValue && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Custom templates aren't triggered automatically — a developer needs to call{" "}
<code className="bg-muted px-1 rounded">sendEmail({"{"} type: "{typeValue}", data {"}"})</code> from code.
</div>
)}
</div>
);
}
// ─── Step 3 — Content ─────────────────────────────────────────────────────────
function StepContent({ register, errors, bodyMarkdown }) {
const [showPreview, setShowPreview] = useState(false);
return (
<div className="space-y-4">
<Field label="Subject" required error={errors.subject?.message}>
<Input {...register("subject")} placeholder="e.g. Your Invoice - STARR System" />
</Field>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Body <span className="text-destructive">*</span></Label>
<div className="flex items-center rounded-md border p-0.5">
<Button type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(false)}>
<Code2 className="h-3.5 w-3.5 mr-1.5" /> Markdown
</Button>
<Button type="button" size="sm" variant={showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(true)}>
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Write this in <strong>Markdown</strong> — it's converted to HTML automatically when you save (mandatory
storage format; only HTML is ever sent). Header, footer and signature are fixed and added automatically;
this box is just the message content in between. Reference dynamic values with{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.
</p>
{showPreview ? (
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{bodyMarkdown?.trim() ? <ReactMarkdown>{bodyMarkdown}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
</div>
) : (
<Textarea {...register("body_markdown")} rows={12} className="font-mono text-xs" placeholder={"Dear {{name}},\n\nWelcome to **STARR System**!"} />
)}
{errors.body_markdown?.message && <p className="text-xs text-destructive">{errors.body_markdown.message}</p>}
<MarkdownCheatsheet />
</div>
</div>
);
}
// ─── Step 4 — Review ──────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[100px] shrink-0">{label}</span>
<span className="text-foreground text-right break-words">{value}</span>
</div>
);
}
function StepReview({ data }) {
const cat = getEmailTemplateCategory(data.category);
const CatIcon = cat.icon;
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Template Details</span>
<Badge variant="outline" className={cn("ml-auto gap-1 text-xs", cat.badgeClass)}>
<CatIcon className="h-3 w-3" /> {cat.label}
</Badge>
</div>
<SummaryRow label="Type" value={data.type} />
<SummaryRow label="Label" value={data.label} />
<SummaryRow label="Subject" value={data.subject} />
</div>
<div className="border border-border rounded-lg p-4">
<p className="text-sm font-medium mb-2">Body Preview</p>
<div className="rounded-md border bg-background p-4 text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{data.body_markdown?.trim() ? <ReactMarkdown>{data.body_markdown}</ReactMarkdown> : <p className="text-muted-foreground">No content.</p>}
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
function AddEmailTemplateInner() {
const navigate = useNavigate();
const { createTemplate, loading } = useAdminEmailTemplates();
const [step, setStep] = useState(0);
const {
register,
control,
trigger,
watch,
getValues,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(emailTemplateSchema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const typeValue = watch("type");
const bodyMarkdown = watch("body_markdown");
const handleNext = async () => {
const valid = await trigger(STEPS[step].fields.length ? STEPS[step].fields : undefined);
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
// Called manually — no <form> tag so no accidental submit
const handleCreate = (publish) => handleSubmit(async (data) => {
// body_markdown is what the admin wrote; html_body is what actually
// gets stored/sent — mandatory HTML, converted right before submit.
const result = await createTemplate({ ...data, html_body: markdownToHtml(data.body_markdown), publish });
if (result) navigate("/admin/email-templates");
})();
return (
// ← plain div, no <form> — prevents any accidental submit on button clicks
<section className="bg-muted/60 min-h-full">
<PageMeta title="Add Email Template - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="max-w-2xl mx-auto w-full space-y-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: "Add Template" },
]} />
<div>
<h1 className="text-xl font-semibold tracking-tight">Add Email Template</h1>
<p className="text-sm text-muted-foreground mt-1">
Define a new email type — category, details, and body content.
</p>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="border border-border rounded-xl p-5 bg-card min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && <StepCategory control={control} error={errors.category?.message} />}
{step === 1 && <StepDetails register={register} errors={errors} typeValue={typeValue} />}
{step === 2 && <StepContent register={register} errors={errors} bodyMarkdown={bodyMarkdown} />}
{step === 3 && <StepReview data={getValues()} />}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep((s) => s - 1)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<div className="flex items-center gap-3">
<Button
type="button" // ← type="button", not "submit"
variant="outline"
disabled={loading}
onClick={() => handleCreate(false)} // ← called manually
>
Save as Draft
</Button>
<Button
type="button"
disabled={loading}
onClick={() => handleCreate(true)}
>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send Now
</Button>
</div>
)}
</div>
</div>
</div>
</section>
);
}
export default function AddEmailTemplate() {
return (
<AdminEmailTemplateProvider>
<AddEmailTemplateInner />
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,297 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { ArrowLeft, House, Lock, Eye, Code2, Send, Clock3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailTemplateProvider,
useAdminEmailTemplates,
} from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_PLACEHOLDERS } from "@/data/emailTemplatePlaceholders.data";
import { EMAIL_TEMPLATE_CATEGORIES } from "@/data/emailTemplateCategories.data";
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
import { markdownToHtml } from "@/utils/markdownToHtml.util";
import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet";
import { cn } from "@/lib/utils";
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function EditEmailTemplateInner() {
const navigate = useNavigate();
const { id } = useParams();
const { template, loading, fetchTemplate, updateTemplate } = useAdminEmailTemplates();
const [label, setLabel] = useState("");
const [category, setCategory] = useState("other");
const [subject, setSubject] = useState("");
const [bodyValue, setBodyValue] = useState(""); // Markdown source (markdown mode) or raw HTML (legacy mode)
const [errors, setErrors] = useState({});
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (id) fetchTemplate(id);
}, [id]);
useEffect(() => {
if (template) {
setLabel(template.label ?? "");
setCategory(template.category ?? "other");
// Prefer whatever's pending (unsent) over the live version, so
// reopening a template with pending changes resumes editing them.
setSubject(template.draft_subject ?? template.subject ?? "");
const markdown = template.draft_body_markdown ?? template.body_markdown;
setBodyValue(markdown ?? template.draft_html_body ?? template.html_body ?? "");
}
}, [template]);
const isSystem = template?.is_system;
const status = STATUS_META[template?.status] ?? STATUS_META.draft;
const pending = hasPendingChanges(template);
const knownPlaceholders = EMAIL_TEMPLATE_PLACEHOLDERS[template?.type] ?? null;
// Templates authored via the Markdown editor have a recorded Markdown
// source; templates from before that feature (all 8 system templates
// included) don't — those keep editing html_body/draft_html_body directly.
const isMarkdownMode = (template?.draft_body_markdown ?? template?.body_markdown) != null;
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (!subject.trim()) e.subject = "Subject is required.";
if (!bodyValue.trim()) e.body = isMarkdownMode ? "Body is required." : "HTML body is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async (publish) => {
if (!validate()) return;
const payload = {
label: label.trim(),
category,
subject: subject.trim(),
publish,
};
if (isMarkdownMode) {
payload.body_markdown = bodyValue;
payload.html_body = markdownToHtml(bodyValue);
} else {
payload.html_body = bodyValue;
}
const result = await updateTemplate(id, payload);
if (result) navigate("/admin/email-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Edit Email Template - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: template?.label ?? "Edit" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">Edit Email Template</h1>
{template && (
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">Update this email's category, subject and body.</p>
</div>
</div>
{pending && (
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
This template has <strong>pending changes</strong> that haven't gone out yet — the version
currently emailed to users is the last one you sent. Press <strong>Send</strong> below to
publish these edits, or <strong>Save as Draft</strong> to keep working without publishing.
</p>
</div>
)}
{isSystem && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> template — code sends it by referencing this exact type,
so the type is locked. Category, label, subject and body are still fully editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Template Details">
<div className="space-y-1.5">
<Label>Type</Label>
<Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed after creation.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Invoice Receipt" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>{cat.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Organizational only — doesn't affect how or when this email is sent.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="subject">Subject <span className="text-destructive">*</span></Label>
<Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Your Invoice - STARR System" />
<FieldError message={errors.subject} />
</div>
</SectionCard>
<SectionCard>
<div className="flex items-center justify-between border-b pb-3">
<p className="text-sm font-semibold">{isMarkdownMode ? "Body" : "HTML Body"}</p>
<div className="flex items-center rounded-md border p-0.5">
<Button
type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"}
className="h-7 px-2" onClick={() => setShowPreview(false)}
>
<Code2 className="h-3.5 w-3.5 mr-1.5" /> {isMarkdownMode ? "Markdown" : "HTML"}
</Button>
<Button
type="button" size="sm" variant={showPreview ? "secondary" : "ghost"}
className="h-7 px-2" onClick={() => setShowPreview(true)}
>
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground -mt-1">
{isMarkdownMode ? (
<>
Write this in <strong>Markdown</strong> — it's converted to HTML automatically when
you save (mandatory storage format; only HTML is ever sent). Header, footer and
signature are fixed and added automatically; this box is just the message content in between.
</>
) : (
<>
This template predates Markdown support, so it's edited as raw HTML directly — there's
no visual/drag-and-drop builder. Header, footer and signature are fixed and added
automatically; this box is just the message content in between.
</>
)}
</p>
{(knownPlaceholders !== null) && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
{knownPlaceholders.length ? (
<div className="flex flex-wrap gap-1.5">
{knownPlaceholders.map((ph) => (
<Badge key={ph} variant="outline" className="font-mono text-[10px]">
{`{{${ph}}}`}
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This template has no dynamic placeholders.</p>
)}
</div>
)}
{showPreview ? (
isMarkdownMode ? (
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{bodyValue.trim() ? <ReactMarkdown>{bodyValue}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
</div>
) : (
<div
className="rounded-md border bg-background p-4 min-h-[220px] text-sm"
style={{ fontFamily: "Arial, sans-serif" }}
dangerouslySetInnerHTML={{ __html: bodyValue || "<p class='text-muted-foreground'>Nothing to preview yet.</p>" }}
/>
)
) : (
<Textarea
id="body"
value={bodyValue}
onChange={(e) => setBodyValue(e.target.value)}
rows={14}
className="font-mono text-xs"
placeholder={isMarkdownMode ? "Dear {{name}},\n\nWelcome to **STARR System**!" : "<p>Dear {{name}},</p>"}
/>
)}
<FieldError message={errors.body} />
{isMarkdownMode && <MarkdownCheatsheet />}
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
Save as Draft
</Button>
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
export default function EditEmailTemplate() {
return (
<AdminEmailTemplateProvider>
<EditEmailTemplateInner />
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { House, X, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailBroadcastProvider,
useAdminEmailBroadcasts,
} from "@/contexts/AdminEmailBroadcastContext";
import { TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { EMAIL_BROADCAST_STATUS_MAP } from "@/data/emailBroadcastStatus.data";
import { cn } from "@/lib/utils";
const POLL_MS = 3000;
function ProgressBar({ sent, failed, total }) {
const donePct = total ? Math.min(100, ((sent + failed) / total) * 100) : 0;
const failedPct = total ? Math.min(100, (failed / total) * 100) : 0;
return (
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
<div className="h-full bg-emerald-500" style={{ width: `${donePct - failedPct}%` }} />
<div className="h-full bg-destructive" style={{ width: `${failedPct}%` }} />
</div>
);
}
function BroadcastRow({ item, onCancel }) {
const status = EMAIL_BROADCAST_STATUS_MAP[item.status] ?? EMAIL_BROADCAST_STATUS_MAP.queued;
const target = TARGET_TYPE_MAP[item.target_type];
const cancelable = item.status === "queued" || item.status === "sending";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{item.template?.label ?? "(deleted template)"}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{target?.label ?? item.target_type}
{item.target_id && <span className="font-mono ml-1">#{item.target_id}</span>}
{" · "}
{new Date(item.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className={cn("text-[11px]", status.badgeClass)}>{status.label}</Badge>
{cancelable && (
<Button type="button" variant="ghost" size="icon" onClick={() => onCancel(item)} title="Cancel">
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<ProgressBar sent={item.sent_count} failed={item.failed_count} total={item.total_recipients} />
<p className="text-xs text-muted-foreground">
{item.sent_count} sent
{item.failed_count > 0 && <span className="text-destructive"> · {item.failed_count} failed</span>}
{" "}/ {item.total_recipients} total
</p>
</div>
);
}
function EmailBroadcastsInner() {
const navigate = useNavigate();
const { broadcasts, loading, fetchBroadcasts, fetchBroadcastsQuiet, cancelBroadcast } = useAdminEmailBroadcasts();
const pollRef = useRef(null);
useEffect(() => { fetchBroadcasts(); }, []);
useEffect(() => {
const hasActive = broadcasts.some((b) => b.status === "queued" || b.status === "sending");
if (hasActive && !pollRef.current) {
pollRef.current = setInterval(fetchBroadcastsQuiet, POLL_MS);
} else if (!hasActive && pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}, [broadcasts, fetchBroadcastsQuiet]);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Sent Email History - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-3xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: "Sent History" },
]} />
</div>
<div className="mb-6">
<h1 className="text-xl font-semibold">Sent Email History</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Every broadcast queued from an email template, and how far delivery has gotten.
Sending is paced in the background — this page auto-refreshes while anything is in progress.
</p>
</div>
<Separator className="mb-5" />
{loading && !broadcasts.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !broadcasts.length ? (
<div className="text-center py-12 space-y-3">
<Send className="h-6 w-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">No broadcasts sent yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-templates")}>
Back to Email Templates
</Button>
</div>
) : (
<div className="space-y-3">
{broadcasts.map((item) => (
<BroadcastRow key={item.email_broadcast_id} item={item} onCancel={(b) => cancelBroadcast(b.email_broadcast_id)} />
))}
</div>
)}
</div>
</div>
</section>
);
}
export default function EmailBroadcasts() {
return (
<AdminEmailBroadcastProvider>
<EmailBroadcastsInner />
</AdminEmailBroadcastProvider>
);
}
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Pencil, Trash2, Mail, Lock, Send, Clock3, History } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailTemplateProvider,
useAdminEmailTemplates,
} from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory, isBroadcastable } from "@/data/emailTemplateCategories.data";
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
import { AdminEmailBroadcastProvider } from "@/contexts/AdminEmailBroadcastContext";
import { SendEmailBroadcastDialog } from "@/components/generic/SendEmailBroadcastDialog";
import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit, onDelete, onSend }) {
const cat = getEmailTemplateCategory(item.category);
const CatIcon = cat.icon;
const status = STATUS_META[item.status] ?? STATUS_META.draft;
const pending = hasPendingChanges(item);
return (
<div className="rounded-lg border bg-card p-5 flex flex-col gap-4 h-full">
<div className="flex items-start justify-between gap-2">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<Mail className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<p className="text-sm font-semibold truncate">{item.label}</p>
{item.is_system && (
<Badge variant="secondary" className="gap-1 shrink-0">
<Lock className="h-2.5 w-2.5" /> System
</Badge>
)}
</div>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.type}</code>
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
<span className="text-foreground">{item.subject || item.draft_subject || "No subject yet"}</span>
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge variant="outline" className={cn("gap-1 text-[11px]", cat.badgeClass)}>
<CatIcon className="h-3 w-3" /> {cat.label}
</Badge>
<Badge variant="outline" className={cn("gap-1 text-[11px]", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label}
</Badge>
{pending && (
<Badge variant="outline" className="gap-1 text-[11px] bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700">
<Clock3 className="h-3 w-3" /> Pending changes
</Badge>
)}
</div>
{isBroadcastable(item) && (
<Button type="button" size="sm" variant="outline" className="gap-1.5" onClick={() => onSend(item)}>
<Send className="h-3.5 w-3.5" /> Send to Recipients
</Button>
)}
</div>
);
}
function EmailTemplatesInner() {
const navigate = useNavigate();
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminEmailTemplates();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const [activeCategory, setActiveCategory] = useState("all");
const [sendTarget, setSendTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteTemplate(deleteTarget.email_template_id);
setDeleting(false);
setDeleteTarget(null);
};
const filtered = useMemo(
() => activeCategory === "all" ? templates : templates.filter((t) => t.category === activeCategory),
[templates, activeCategory]
);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Email Templates - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-6xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Email Templates</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Subject lines and message content for every automated email STARR sends.
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Send className="h-3 w-3 text-emerald-600" />
{templates.filter((t) => t.status === "sent").length} sent
</span>
<span className="flex items-center gap-1">
<Pencil className="h-3 w-3" />
{templates.filter((t) => t.status === "draft").length} draft
</span>
{templates.some(hasPendingChanges) && (
<span className="flex items-center gap-1 text-amber-600">
<Clock3 className="h-3 w-3" />
{templates.filter(hasPendingChanges).length} with pending changes
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-broadcasts")}>
<History className="h-4 w-4 mr-2" />
Sent History
</Button>
<Button size="sm" onClick={() => navigate("/admin/email-templates/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Template
</Button>
</div>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>System</strong> templates are sent automatically by platform code and cannot be
deleted or have their type changed — the subject and body stay fully editable.
</p>
<p>
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> template is the version actually used
for real emails right now. Editing a Sent template doesn't change what goes out immediately —
it's held as a pending change until you press <strong>Send</strong> again to publish it. A
brand-new <strong>Draft</strong> isn't used for anything until it's sent for the first time.
</p>
<p>
<strong>Limitations:</strong> the page layout (header, footer, signature) is fixed and cannot
be customized from here — you can only edit the subject and the body content in between.
Only plain HTML is supported in the body (no visual/drag-and-drop builder) — no scripts and
no conditional logic, just straight <code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens
that get swapped for real values when the email is sent.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-5">
<Button
type="button" size="sm" variant={activeCategory === "all" ? "secondary" : "outline"}
onClick={() => setActiveCategory("all")}
>
All ({templates.length})
</Button>
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const count = templates.filter((t) => t.category === cat.value).length;
return (
<Button
key={cat.value}
type="button" size="sm"
variant={activeCategory === cat.value ? "secondary" : "outline"}
onClick={() => setActiveCategory(cat.value)}
className="gap-1.5"
>
<Icon className="h-3.5 w-3.5" /> {cat.label} ({count})
</Button>
);
})}
</div>
<Separator className="mb-5" />
{loading && !templates.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !filtered.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No email templates found.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((item) => (
<TemplateCard
key={item.email_template_id}
item={item}
onEdit={(t) => navigate(`/admin/email-templates/${t.email_template_id}/edit`)}
onDelete={(t) => setDeleteTarget(t)}
onSend={(t) => setSendTarget(t)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Email Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Send to Recipients dialog */}
<SendEmailBroadcastDialog
open={!!sendTarget}
onOpenChange={(open) => { if (!open) setSendTarget(null); }}
template={sendTarget}
/>
</section>
);
}
export default function EmailTemplates() {
return (
<AdminEmailTemplateProvider>
<AdminEmailBroadcastProvider>
<EmailTemplatesInner />
</AdminEmailBroadcastProvider>
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,182 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_id: z.string().nullable().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please select a specific target.",
path: ["target_id"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddNotificationBroadcast() {
const navigate = useNavigate();
const { createBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: "",
message: "",
target_type: undefined,
target_id: null,
},
});
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "New" },
];
const onSubmit = async (values) => {
const payload = {
...values,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) navigate("/admin/notifications");
};
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New notification</h1>
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
</SectionCard>
<SectionCard title="Target" description="Who receives this notification when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save draft
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,202 @@
// modules/admin/pages/notifications/EditNotificationBroadcast.jsx
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_id: z.string().nullable().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please select a specific target.",
path: ["target_id"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditNotificationBroadcast() {
const navigate = useNavigate();
const { broadcastId } = useParams();
const { fetchBroadcast, updateBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: "",
message: "",
target_type: undefined,
target_id: null,
},
});
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "Edit" },
];
// ─── Load existing broadcast data ─────────────────────────────────────────
useEffect(() => {
(async () => {
const res = await fetchBroadcast(broadcastId);
const b = res?.data?.data ?? null;
if (!b) return;
reset({
title: b.title ?? "",
message: b.message ?? "",
target_type: b.target_type ?? undefined,
target_id: b.target_id ?? null,
});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
const onSubmit = async (values) => {
const payload = {
...values,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
updatedBy: user?.user_id ?? null,
};
const res = await updateBroadcast(broadcastId, payload);
if (res) navigate("/admin/notifications");
};
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit notification</h1>
<p className="text-sm text-muted-foreground mb-6">Only draft notifications can be edited.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
</SectionCard>
<SectionCard title="Target" description="Who receives this notification when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,276 @@
// modules/admin/pages/notifications/NotificationBroadcastList.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { TablePagination } from "@/components/generic/Table/TablePagination";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
export default function NotificationBroadcastList() {
const navigate = useNavigate();
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12);
function buildFilters() {
const filters = [];
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
if (search.trim()) filters.push({ id: "title", value: search.trim() });
return filters;
}
useEffect(() => {
fetchBroadcasts({ page: 1, limit, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, search, limit]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications" },
];
const total = pagination?.totalRecords ?? broadcasts.length;
const draftCount = broadcasts.filter((b) => b.status === "draft").length;
const sentCount = broadcasts.filter((b) => b.status === "sent").length;
async function handleSend(broadcastId) {
await sendBroadcast(broadcastId);
}
async function handleArchive(broadcastId) {
await archiveBroadcast(broadcastId);
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={items} />
</div>
<div className="w-full flex flex-col gap-6 pb-10">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/admin/notifications/settings")}>
<Settings className="size-4" />
Settings
</Button>
<Button onClick={() => navigate("/admin/notifications/add")}>
<Plus className="size-4" />
New notification
</Button>
</div>
</div>
{/* ── Stat cards ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatCard label="Total" value={total} />
<StatCard label="Drafts" value={draftCount} tone="muted" />
<StatCard label="Sent" value={sentCount} tone="success" />
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{BROADCAST_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search notifications..."
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
) : broadcasts.length === 0 ? (
<EmptyState onCreate={() => navigate("/admin/notifications/add")} />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{broadcasts.map((b) => (
<BroadcastCard
key={b.broadcast_id}
broadcast={b}
onView={() => navigate(`/admin/notifications/${b.broadcast_id}/view`)}
onEdit={() => navigate(`/admin/notifications/${b.broadcast_id}/edit`)}
onSend={() => handleSend(b.broadcast_id)}
onArchive={() => handleArchive(b.broadcast_id)}
/>
))}
</div>
<div className="bg-background rounded-lg border">
<TablePagination
pagination={pagination}
rowCount={broadcasts.length}
totalRecords={pagination?.totalRecords}
recordLabel="notification"
pageSizeOptions={[12, 24, 48, 96]}
onPageChange={(page) => fetchBroadcasts({ page, limit, filters: buildFilters() })}
onPageSizeChange={(size) => setLimit(size)}
/>
</div>
</>
)}
</div>
</div>
</section>
);
}
// ─── Stat card ──────────────────────────────────────────────────────────────
function StatCard({ label, value, tone = "default" }) {
const toneClass = {
default: "text-foreground",
success: "text-green-600 dark:text-green-400",
muted: "text-muted-foreground",
}[tone];
return (
<div className="bg-background rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">{label}</p>
<p className={`text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
);
}
// ─── Broadcast card ─────────────────────────────────────────────────────────
function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
const { fmtDateTime } = useDateFormat();
const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
const TargetIcon = targetMeta.icon ?? Users;
const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
const isDraft = broadcast.status === "draft";
return (
<div className="bg-background rounded-lg border overflow-hidden flex flex-col">
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<div className="flex items-center gap-1.5 mb-1.5">
<span className={`text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
{statusMeta.label ?? broadcast.status}
</span>
<span className="flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
<TargetIcon className="size-3" />
{targetText}
</span>
</div>
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled notification"}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
{broadcast.sent_at && (
<p className="text-xs text-muted-foreground mt-1.5">
Sent {fmtDateTime(broadcast.sent_at)} &middot; {broadcast.recipient_count ?? 0} recipient(s)
</p>
)}
</button>
<div className="mt-auto flex items-center justify-end gap-1 pt-2">
{isDraft && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" aria-label="Send">
<Send className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onSend}>Send</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{isDraft && (
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
<Edit className="size-3.5" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be moved to archived notifications. You can restore it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onArchive}>Archive</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
}
// ─── Empty state ────────────────────────────────────────────────────────────
function EmptyState({ onCreate }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No notifications yet</p>
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New notification
</Button>
</div>
);
}
@@ -0,0 +1,162 @@
// modules/admin/pages/notifications/NotificationSettings.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ArrowLeft, Clock } from "lucide-react";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CRON_PRESET_OPTIONS, JOB_LABELS } from "@/data/cronPresets.data";
function SectionCard({ children }) {
return <div className="rounded-lg border bg-card p-4">{children}</div>;
}
export default function NotificationSettings() {
const navigate = useNavigate();
const { user } = useAuth();
const { fmtDateTime } = useDateFormat();
const [settings, setSettings] = useState([]);
const [loading, setLoading] = useState(true);
const [savingJob, setSavingJob] = useState(null);
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "Settings" },
];
async function fetchSettings() {
setLoading(true);
try {
const { data } = await api.get("/admin/notification-settings");
setSettings(data?.data ?? []);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to load notification settings.");
} finally {
setLoading(false);
}
}
useEffect(() => { fetchSettings(); }, []);
async function handleToggle(jobName, enabled) {
setSavingJob(jobName);
try {
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
enabled,
updatedBy: user?.user_id ?? null,
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update setting.");
} finally {
setSavingJob(null);
}
}
async function handlePresetChange(jobName, preset) {
setSavingJob(jobName);
try {
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
preset,
updatedBy: user?.user_id ?? null,
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
toast.success("Schedule updated — took effect immediately, no restart needed.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update schedule.");
} finally {
setSavingJob(null);
}
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10 space-y-5">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")} aria-label="Back">
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Notification Settings</h1>
<p className="text-sm text-muted-foreground">
Toggle and reschedule automatic notifications without a deploy.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
) : (
<div className="space-y-3">
{settings.map((s) => {
const meta = JOB_LABELS[s.job_name] ?? {};
const isSaving = savingJob === s.job_name;
return (
<SectionCard key={s.job_name}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<p className="text-sm font-medium">{meta.label ?? s.label ?? s.job_name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description ?? s.description}</p>
{s.updatedAt && (
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<Clock className="size-3" />
Last updated {fmtDateTime(s.updatedAt)}
</p>
)}
</div>
<Switch
checked={s.enabled}
disabled={isSaving}
onCheckedChange={(v) => handleToggle(s.job_name, v)}
/>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Runs:</span>
<Select
value={s.preset ?? undefined}
disabled={isSaving}
onValueChange={(v) => handlePresetChange(s.job_name, v)}
>
<SelectTrigger className="w-[180px] h-8 text-xs">
<SelectValue placeholder={s.schedule} />
</SelectTrigger>
<SelectContent>
{CRON_PRESET_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{isSaving && <Spinner className="size-3.5" />}
</div>
</SectionCard>
);
})}
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,233 @@
// modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Edit, ArrowLeft, Send, Users, FileText, BadgeCheck, Megaphone } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
// ─── Shared helpers ─────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
);
}
function Field({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
</div>
);
}
// ─── Tab: Content ───────────────────────────────────────────────────────────
function ContentTab({ broadcast }) {
return (
<SectionCard icon={FileText} title="Content">
<Field label="Title">{broadcast.title || "—"}</Field>
<Field label="Message">
<span className="font-normal">{broadcast.message || "—"}</span>
</Field>
</SectionCard>
);
}
// ─── Tab: Delivery ───────────────────────────────────────────────────────────
function DeliveryTab({ broadcast, targetText, fmtDateTime }) {
return (
<SectionCard icon={Send} title="Delivery">
<div className="grid grid-cols-2 gap-4">
<Field label="Target">{targetText}</Field>
<Field label="Recipients">{broadcast.recipient_count ?? 0}</Field>
<Field label="Sent at">{broadcast.sent_at ? fmtDateTime(broadcast.sent_at) : "Not sent yet"}</Field>
</div>
</SectionCard>
);
}
// ─── Tab: Audit ───────────────────────────────────────────────────────────────
function AuditTab({ broadcast, fmtDateTime }) {
return (
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<Field label="Created by">{broadcast.creator?.full_name || "—"}</Field>
<Field label="Created at">{fmtDateTime(broadcast.createdAt)}</Field>
<Field label="Last updated by">{broadcast.updater?.full_name || "—"}</Field>
<Field label="Last updated at">{fmtDateTime(broadcast.updatedAt)}</Field>
</div>
</SectionCard>
);
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "content", label: "Content", icon: FileText },
{ key: "delivery", label: "Delivery", icon: Send },
{ key: "audit", label: "Audit", icon: BadgeCheck },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function ViewNotificationBroadcast() {
const navigate = useNavigate();
const { broadcastId } = useParams();
const { fetchBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
const { fmtDateTime } = useDateFormat();
const [broadcast, setBroadcast] = useState(null);
const [activeTab, setActiveTab] = useState("content");
useEffect(() => {
(async () => {
const res = await fetchBroadcast(broadcastId);
setBroadcast(res?.data?.data ?? null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
if (loading && !broadcast) {
return (
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
);
}
if (!broadcast) {
return (
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<p className="text-sm text-muted-foreground">Notification not found.</p>
</div>
);
}
const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
const TargetIcon = targetMeta.icon ?? Users;
const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
const isDraft = broadcast.status === "draft";
async function handleSend() {
const res = await sendBroadcast(broadcastId);
if (res) {
const refreshed = await fetchBroadcast(broadcastId);
setBroadcast(refreshed?.data?.data ?? broadcast);
}
}
return (
<div className="flex flex-col min-h-screen bg-muted/60">
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<Megaphone className="h-5 w-5 text-muted-foreground" />
{broadcast.title || "Untitled notification"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
{statusMeta.label ?? broadcast.status}
</Badge>
<Badge variant="secondary" className="gap-1">
<TargetIcon className="size-3" />
{targetText}
</Badge>
</div>
</div>
{isDraft && (
<div className="flex items-center gap-2 shrink-0">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Send className="size-4" />
Send
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSend}>Send</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button size="sm" onClick={() => navigate(`/admin/notifications/${broadcastId}/edit`)}>
<Edit className="size-4" />
Edit
</Button>
</div>
)}
</div>
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{activeTab === "content" && <ContentTab broadcast={broadcast} />}
{activeTab === "delivery" && <DeliveryTab broadcast={broadcast} targetText={targetText} fmtDateTime={fmtDateTime} />}
{activeTab === "audit" && <AuditTab broadcast={broadcast} fmtDateTime={fmtDateTime} />}
</div>
</div>
</div>
);
}
@@ -1,34 +1,73 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
import api from '@/utils/api.util';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ClipboardList } from 'lucide-react';
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: 'Details', icon: FileText },
{ id: 1, label: 'Assign Groups', icon: Users },
{ id: 2, label: 'Review', icon: ClipboardList },
];
// ─── Summary row ──────────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[120px] shrink-0">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
export default function CreateTaskList() {
const navigate = useNavigate();
const { createTaskList, assignGroups, loading } = useAdminTask();
const [step, setStep] = useState(0);
const [form, setForm] = useState({ name: '', description: '' });
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [allGroups, setAllGroups] = useState([]);
const [errors, setErrors] = useState({});
const validate = () => {
// Fetch groups for the review step's summary (names, not just ids)
useEffect(() => {
api.get('/admin/groups', { params: { limit: 500 } })
.then((res) => setAllGroups(res.data?.data?.data ?? res.data?.data ?? []))
.catch(() => {});
}, []);
const validateDetails = () => {
const e = {};
if (!form.name.trim()) e.name = 'Task list name is required.';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
const handleNext = () => {
if (step === 0 && !validateDetails()) return;
setStep((s) => s + 1);
};
const handleBack = () => {
if (step === 0) navigate('/admin/taskList');
else setStep((s) => s - 1);
};
const handleCreate = async () => {
if (!validateDetails()) { setStep(0); return; }
const created = await createTaskList({
name: form.name.trim(),
@@ -45,10 +84,17 @@ export default function CreateTaskList() {
navigate(`/admin/taskList/${created.task_list_id}/view`);
};
const selectedGroupNames = allGroups
.filter((g) => selectedGroupIds.includes(g.group_id))
.map((g) => g.name);
return (
// ← plain div, no <form> — prevents any accidental submit on button clicks
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="mx-auto w-full lg:w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate('/admin/taskList')}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -57,37 +103,80 @@ export default function CreateTaskList() {
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Card className="lg:w-2xl">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div className="space-y-3">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Onboarding Tasks"
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
'h-8 w-8 rounded-full flex items-center justify-center border transition-colors',
isDone && 'bg-emerald-600 border-emerald-600 text-white',
isActive && 'border-primary bg-primary text-primary-foreground',
!isActive && !isDone && 'border-border bg-background text-muted-foreground'
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
'text-[11px] font-medium whitespace-nowrap hidden sm:block',
isActive ? 'text-foreground' : 'text-muted-foreground',
isDone ? 'text-emerald-600' : ''
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
'flex-1 h-px mx-2 mb-4 transition-colors',
step > i ? 'bg-emerald-600' : 'bg-border'
)} />
)}
</div>
);
})}
</div>
{/* Description */}
<div className="space-y-3">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
{/* Step content */}
<Card>
<CardContent className="space-y-4 min-h-[280px]">
<h2 className="text-base font-medium">{STEPS[step].label}</h2>
{/* ── Step 1: Details ── */}
{step === 0 && (
<div className="space-y-4">
<div className="space-y-3">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Onboarding Tasks"
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
)}
</div>
<div className="space-y-3">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
</div>
</div>
)}
{/* Groups */}
{/* ── Step 2: Assign Groups ── */}
{step === 1 && (
<div className="space-y-3">
<Label>
Assign to Groups
@@ -105,25 +194,64 @@ export default function CreateTaskList() {
Members of selected groups will be able to see and complete this task list.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/taskList')}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Creating…' : 'Create Task List'}
</Button>
{/* ── Step 3: Review ── */}
{step === 2 && (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Task List Details</span>
</div>
<SummaryRow label="Name" value={form.name || '—'} />
<SummaryRow label="Description" value={form.description || '—'} />
</div>
<div className="border border-border rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Assigned Groups</span>
</div>
{selectedGroupNames.length > 0 ? (
<div className="flex flex-wrap gap-1.5 pt-1">
{selectedGroupNames.map((name) => (
<Badge key={name} variant="secondary" className="text-xs">{name}</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No groups assigned — task list will not be visible to any users yet.</p>
)}
</div>
</div>
</form>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
type="button" // ← type="button", not "submit"
disabled={loading}
onClick={handleCreate} // ← called manually
>
{loading ? 'Creating…' : 'Create Task List'}
</Button>
)}
</div>
</div>
</div >
</div>
);
}
}
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import { format, parseISO, isValid } from 'date-fns';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { cn } from '@/lib/utils';
import RequirementBuilder from './RequirementBuilder';
@@ -9,17 +12,74 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ArrowLeft } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Upload, BookOpen, Layers, Clock } from 'lucide-react';
import DeadlinePicker from '@/components/generic/DeadlinePicker';
// ── Requirement validation schema ─────────────────────────────────────────────
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
const requirementSchema = z.object({
type: z.string(),
reference_id: z.string().optional(),
duration_seconds: z.number().optional(),
}).passthrough().superRefine((req, ctx) => {
if (!READ_TYPES.includes(req.type)) return;
if (!req.reference_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
} else if ((req.duration_seconds ?? -1) === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
}
});
const taskSchema = z.object({
name: z.string().min(1, 'Task name is required.'),
requirements: z.array(requirementSchema),
});
// ── Requirement type labels/icons for the review step ─────────────────────────
const REQUIREMENT_TYPE_META = {
visit_link: { label: 'Visit a Link', icon: LinkIcon },
upload_file: { label: 'Upload a File', icon: Upload },
read_course: { label: 'Read a Course', icon: BookOpen },
read_unit: { label: 'Read a Unit', icon: Layers },
read_lesson: { label: 'Read a Lesson', icon: FileText },
};
function requirementSummaryText(req) {
if (req.type === 'visit_link') return req.link_url || '—';
if (req.type === 'upload_file') {
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
}
return req.reference_label || '—';
}
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: 'Task Details', icon: FileText },
{ id: 1, label: 'Requirements', icon: ListChecks },
{ id: 2, label: 'Review', icon: ClipboardList },
];
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[120px] shrink-0">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default function CreateTask() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
const [step, setStep] = useState(0);
const [form, setForm] = useState({
name: '',
description: '',
@@ -28,7 +88,7 @@ export default function CreateTask() {
});
const [errors, setErrors] = useState({});
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
useEffect(() => {
@@ -37,30 +97,71 @@ export default function CreateTask() {
fetchLessonsFlat().then((d) => d && setLessons(d));
}, []);
const validate = () => {
const e = {};
if (!form.name.trim()) e.name = 'Task name is required.';
setErrors(e);
return Object.keys(e).length === 0;
const validateStep = (s) => {
const schema = s === 0 ? taskSchema.pick({ name: true }) : taskSchema.pick({ requirements: true });
const result = schema.safeParse(form);
if (!result.success) {
const e = {};
const issues = result.error.issues;
if (s === 0) {
const nameIssue = issues.find((i) => i.path[0] === 'name');
if (nameIssue) e.name = nameIssue.message;
} else if (issues.some((i) => i.path[0] === 'requirements')) {
e.requirements = 'Some requirements have issues — check above.';
}
setErrors(e);
return false;
}
setErrors({});
return true;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
const handleNext = () => {
if (!validateStep(step)) return;
setStep((s) => s + 1);
};
const handleBack = () => {
if (step === 0) navigate(`/admin/taskList/${taskListId}/tasks`);
else setStep((s) => s - 1);
};
const handleCreate = async () => {
const result = taskSchema.safeParse(form);
if (!result.success) {
// Route back to whichever step has the problem
const issues = result.error.issues;
if (issues.some((i) => i.path[0] === 'name')) { setStep(0); validateStep(0); return; }
if (issues.some((i) => i.path[0] === 'requirements')) { setStep(1); validateStep(1); return; }
return;
}
const created = await createTask(taskListId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
requirements: form.requirements,
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
// strip duration_seconds — it's only used for local validation
requirements: form.requirements.map((r) => {
const req = { ...r };
delete req.duration_seconds;
return req;
}),
});
if (created) navigate(`/admin/taskList/${taskListId}/tasks/${created.task_id}/view`);
if (created) navigate(`/admin/taskList/${taskListId}/tasks`);
};
const formattedDeadline = (() => {
if (!form.deadline) return null;
const d = parseISO(form.deadline);
return isValid(d) ? format(d, 'MMM d, yyyy h:mm a') : null;
})();
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-6">
<div className="mx-auto w-full lg:w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
<ArrowLeft className="h-4 w-4" />
@@ -68,9 +169,46 @@ export default function CreateTask() {
<h1 className="text-xl font-semibold">Create Task</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Task info */}
<Card className="lg:w-2xl">
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
'h-8 w-8 rounded-full flex items-center justify-center border transition-colors',
isDone && 'bg-emerald-600 border-emerald-600 text-white',
isActive && 'border-primary bg-primary text-primary-foreground',
!isActive && !isDone && 'border-border bg-background text-muted-foreground'
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
'text-[11px] font-medium whitespace-nowrap hidden sm:block',
isActive ? 'text-foreground' : 'text-muted-foreground',
isDone ? 'text-emerald-600' : ''
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
'flex-1 h-px mx-2 mb-4 transition-colors',
step > i ? 'bg-emerald-600' : 'bg-border'
)} />
)}
</div>
);
})}
</div>
{/* ── Step 1: Task Details ── */}
{step === 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
@@ -95,7 +233,6 @@ export default function CreateTask() {
/>
</div>
{/* Deadline — date popover + time input */}
<div className="space-y-3">
<Label>Deadline</Label>
<DeadlinePicker
@@ -104,11 +241,12 @@ export default function CreateTask() {
disabled={loading}
/>
</div>
</CardContent>
</Card>
)}
{/* Requirements */}
{/* ── Step 2: Requirements ── */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Requirements</CardTitle>
@@ -116,7 +254,7 @@ export default function CreateTask() {
Define what a user needs to do to complete this task.
</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
@@ -124,19 +262,86 @@ export default function CreateTask() {
units={units}
lessons={lessons}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
)}
</CardContent>
</Card>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
Cancel
{/* ── Step 3: Review ── */}
{step === 2 && (
<div className="space-y-4">
<Card>
<CardContent className="space-y-1 pt-6">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Task Details</span>
</div>
<SummaryRow label="Name" value={form.name || '—'} />
<SummaryRow label="Description" value={form.description || '—'} />
<SummaryRow
label="Deadline"
value={formattedDeadline
? <span className="inline-flex items-center gap-1"><Clock className="h-3.5 w-3.5" />{formattedDeadline}</span>
: 'No deadline'}
/>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 pt-6">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Requirements</span>
<Badge variant="secondary" className="ml-auto text-xs">{form.requirements.length}</Badge>
</div>
{form.requirements.length === 0 ? (
<p className="text-sm text-muted-foreground">No requirements added — users will be able to complete this task immediately.</p>
) : (
<div className="space-y-2 pt-1">
{form.requirements.map((req, i) => {
const meta = REQUIREMENT_TYPE_META[req.type];
const Icon = meta?.icon ?? LinkIcon;
return (
<div key={i} className="flex items-center gap-2 border border-border rounded-lg px-3 py-2">
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{i + 1}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{meta?.label ?? req.type}</p>
<p className="text-xs text-muted-foreground truncate">{requirementSummaryText(req)}</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
<Button type="submit" disabled={loading}>
) : (
<Button type="button" disabled={loading} onClick={handleCreate}>
{loading ? 'Creating…' : 'Create Task'}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import { useAdminTask } from '@/contexts/AdminTaskContext';
@@ -12,8 +13,35 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft } from 'lucide-react';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ArrowLeft, TriangleAlert } from 'lucide-react';
// ── Requirement validation schema ─────────────────────────────────────────────
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
const requirementSchema = z.object({
type: z.string(),
reference_id: z.string().optional(),
duration_seconds: z.number().optional(),
}).passthrough().superRefine((req, ctx) => {
if (!READ_TYPES.includes(req.type)) return;
if (!req.reference_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
} else if ((req.duration_seconds ?? -1) === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
}
});
const taskSchema = z.object({
name: z.string().min(1, 'Task name is required.'),
requirements: z.array(requirementSchema),
});
// ─────────────────────────────────────────────────────────────────────────────
const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'in_progress', label: 'In Progress' },
@@ -31,10 +59,14 @@ export default function EditTask() {
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const initialRequirementsRef = useRef(null);
useEffect(() => {
fetchTask(taskListId, taskId).then((data) => {
if (!data) return;
const reqs = data.requirements ?? [];
initialRequirementsRef.current = JSON.stringify(reqs);
setForm({
name: data.name ?? '',
description: data.description ?? '',
@@ -42,7 +74,7 @@ export default function EditTask() {
? new Date(data.deadline).toISOString().slice(0, 16)
: '',
status: data.status ?? 'pending',
requirements: data.requirements ?? [],
requirements: reqs,
});
});
fetchCoursesFlat().then((d) => d && setCourses(d));
@@ -50,26 +82,49 @@ export default function EditTask() {
fetchLessonsFlat().then((d) => d && setLessons(d));
}, [taskListId, taskId]);
const requirementsChanged = () =>
JSON.stringify(form?.requirements ?? []) !== initialRequirementsRef.current;
const validate = () => {
const e = {};
if (!form?.name?.trim()) e.name = 'Task name is required.';
setErrors(e);
return Object.keys(e).length === 0;
const result = taskSchema.safeParse(form ?? {});
if (!result.success) {
const e = {};
const issues = result.error.issues;
const nameIssue = issues.find((i) => i.path[0] === 'name');
if (nameIssue) e.name = nameIssue.message;
if (issues.some((i) => i.path[0] === 'requirements')) {
e.requirements = 'Some requirements have issues — check above.';
}
setErrors(e);
return false;
}
setErrors({});
return true;
};
const handleSubmit = async (e) => {
const doSave = async () => {
const updated = await updateTask(taskListId, taskId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
status: form.status,
// strip duration_seconds — it's only used for local validation
requirements: form.requirements.map(({ duration_seconds, ...req }) => req),
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
const updated = await updateTask(taskListId, taskId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
status: form.status,
requirements: form.requirements,
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
if (requirementsChanged()) {
setConfirmOpen(true);
} else {
doSave();
}
};
if (!form) return (
@@ -81,6 +136,33 @@ export default function EditTask() {
);
return (
<>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TriangleAlert className="size-4 text-amber-500" />
Requirements changed
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2 pt-1">
<span className="block">
You've modified the requirements for this task.
</span>
<span className="block">
All users currently assigned to this task will receive a notification
letting them know the requirements have been updated.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction onClick={doSave} disabled={loading}>
{loading ? 'Saving…' : 'Confirm & Save'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-6">
<div className="flex items-center gap-3">
@@ -150,7 +232,7 @@ export default function EditTask() {
<CardTitle className="text-base">Requirements</CardTitle>
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
@@ -158,6 +240,9 @@ export default function EditTask() {
units={units}
lessons={lessons}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
)}
</CardContent>
</Card>
@@ -173,5 +258,6 @@ export default function EditTask() {
</form>
</div>
</div>
</>
);
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Check } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -9,8 +8,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { resolveTierBadge } from '@/utils/tierBadge.util';
import api from '@/utils/api.util';
// ─── Requirement type config ──────────────────────────────────────────────────
const REQUIREMENT_TYPES = [
@@ -34,54 +34,90 @@ const FILE_TYPE_OPTIONS = [
{ value: 'zip', label: 'ZIP' },
];
// ─── Searchable content picker (Popover + Command + ScrollArea) ───────────────
// renderItem — optional custom JSX per item (defaults to o[labelKey])
// searchKey — optional key whose value cmdk uses for filtering (defaults to labelKey)
function ContentPicker({ value, options, idKey, labelKey, searchKey, placeholder = 'Select…', onSelect, renderItem, listHeight = 'h-48' }) {
// ─── Duration formatter ───────────────────────────────────────────────────────
function fmtDuration(seconds) {
if (!seconds || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h > 0 && m > 0) return `${h}h ${m}m`;
if (h > 0) return `${h}h`;
return `${m}m`;
}
// ─── Tier badge ───────────────────────────────────────────────────────────────
function TierBadge({ subscription, tierMap }) {
const { rank, label, cls } = resolveTierBadge(subscription ?? 'free', tierMap);
return (
<Badge className={`${cls}`}>
{rank > 0 ? <Lock className="size-2.5" /> : <Tag className="size-2.5" />}
{label}
</Badge>
);
}
// ─── Custom content picker ────────────────────────────────────────────────────
function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder = 'Select…', onSelect, renderTrigger, renderItem, listHeight = 'max-h-48' }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) =>
String(o[searchKey] ?? o[labelKey] ?? '').toLowerCase().includes(q)
);
}, [options, query, searchKey, labelKey]);
const selected = options.find((o) => String(o[idKey]) === String(value));
const handleOpenChange = (v) => {
setOpen(v);
if (!v) setQuery('');
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-sm font-normal"
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
>
<span className="truncate">
{selected
? selected[labelKey]
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-none overflow-visible">
<ScrollArea className={listHeight}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchKey ? o[searchKey] : o[labelKey]}
onSelect={() => {
onSelect(o);
setOpen(false);
}}
>
{renderItem ? renderItem(o) : o[labelKey]}
<Check className={cn('ml-auto h-4 w-4 shrink-0', String(value) === String(o[idKey]) ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
<PopoverContent className="p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
<div className="flex items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className={`overflow-y-auto ${listHeight}`}>
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
) : (
filtered.map((o) => (
<div
key={o[idKey]}
role="option"
aria-selected={String(value) === String(o[idKey])}
className={`cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground${String(value) === String(o[idKey]) ? ' bg-accent/50' : ''}`}
onClick={() => { onSelect(o); setOpen(false); setQuery(''); }}
>
{renderItem(o)}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
);
@@ -92,13 +128,10 @@ function createRequirement(type = 'visit_link') {
return {
_key: crypto.randomUUID(),
type,
// visit_link
link_url: '',
link_label: '',
// upload_file
allowed_file_types: [],
max_file_count: 1,
// read_*
reference_id: '',
reference_label: '',
};
@@ -111,17 +144,28 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
: []
);
const [tierCategories, setTierCategories] = useState([]);
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
useEffect(() => {
api.get('/admin/tiers/categories')
.then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => { });
}, []);
const tierMap = useMemo(
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
[tierCategories]
);
const emit = (next) => {
setItems(next);
// strip _key before calling onChange
onChange?.(next.map(({ _key, ...r }) => r));
};
const addItem = () => emit([...items, createRequirement('visit_link')]);
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
const updateItem = (key, patch) =>
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
@@ -135,6 +179,20 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
updateItem(key, { allowed_file_types: next });
};
const handleContentSelect = (key, content, contentType) => {
updateItem(key, {
reference_id: content.uuid,
reference_label: content.title,
duration_seconds: content.duration_seconds ?? 0,
});
if ((content.duration_seconds ?? 0) === 0) {
setNoContentDialog({ title: content.title, contentType });
} else {
const { rank, label } = resolveTierBadge(content.subscription ?? 'free', tierMap);
if (rank > 0) setLockedDialog({ title: content.title, tierLabel: label, contentType });
}
};
return (
<div className="space-y-3">
{items.length === 0 && (
@@ -158,7 +216,6 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{idx + 1}
</Badge>
{/* Type selector */}
<Select
value={item.type}
onValueChange={(v) => updateItem(item._key, { type: v })}
@@ -247,12 +304,12 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{/* ── read_course / read_unit / read_lesson fields ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
<div className="pl-7 space-y-1">
<div className="space-y-1">
<Label className="text-xs">
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
</Label>
{/* Reference picker */}
{/* ── read_course picker ── */}
{item.type === 'read_course' && (
<ContentPicker
value={item.reference_id}
@@ -260,10 +317,33 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
idKey="uuid"
labelKey="title"
placeholder="Select a course"
onSelect={(c) => updateItem(item._key, { reference_id: c.uuid, reference_label: c.title })}
onSelect={(c) => handleContentSelect(item._key, c, 'course')}
renderTrigger={(c) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 truncate text-sm">{c.title}</span>
{fmtDuration(c.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(c) => (
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 text-sm truncate">{c.title}</span>
{fmtDuration(c.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
</span>
)}
</div>
)}
/>
)}
{/* ── read_unit picker ── */}
{item.type === 'read_unit' && (
<ContentPicker
value={item.reference_id}
@@ -272,18 +352,44 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
labelKey="title"
searchKey="_search"
placeholder="Select a unit"
onSelect={(u) => handleContentSelect(item._key, u, 'unit')}
renderTrigger={(u) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{u.course_title} | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
{fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(u) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{u.course_title} &middot; Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{u.course_title} (Course) | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
{fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)}
</span>
)}
</div>
)}
onSelect={(u) => updateItem(item._key, { reference_id: u.uuid, reference_label: u.title })}
/>
)}
{/* ── read_lesson picker ── */}
{item.type === 'read_lesson' && (
<ContentPicker
value={item.reference_id}
@@ -292,22 +398,50 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
labelKey="title"
searchKey="_search"
placeholder="Select a lesson"
listHeight="h-64"
listHeight="max-h-64"
onSelect={(l) => handleContentSelect(item._key, l, 'lesson')}
renderTrigger={(l) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
{fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(l) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{l.course_title}
<span className="mx-1 opacity-50">›</span>
Unit {l.unit_order + 1}
<span className="mx-1 opacity-50">›</span>
Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
{fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)}
</span>
)}
</div>
)}
onSelect={(l) => updateItem(item._key, { reference_id: l.uuid, reference_label: l.title })}
/>
)}
{/* ── inline no-content error ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && item.reference_id && (item.duration_seconds ?? -1) === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive pl-7 pt-1">
<AlertTriangle className="size-3 shrink-0" />
This content has no content detected yet.
</p>
)}
</div>
)}
</CardContent>
@@ -319,6 +453,49 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
<Plus className="h-4 w-4" />
Add Requirement
</Button>
{/* ── Locked tier warning ─────────────────────────────────────────── */}
<AlertDialog open={!!lockedDialog} onOpenChange={(v) => !v && setLockedDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-amber-500" />
Subscription Required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-1">
<span className="block font-medium text-foreground">{lockedDialog?.title}</span>
<span className="block">
This {lockedDialog?.contentType} requires a <strong>{lockedDialog?.tierLabel}</strong> subscription.
Users without the required plan will not be able to complete this requirement.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setLockedDialog(null)}>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── No content warning ──────────────────────────────────────────── */}
<AlertDialog open={!!noContentDialog} onOpenChange={(v) => !v && setNoContentDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-destructive" />
No Content Detected
</AlertDialogTitle>
<AlertDialogDescription className="space-y-1">
<span className="block font-medium text-foreground">{noContentDialog?.title}</span>
<span className="block">
This {noContentDialog?.contentType} does not have any content yet and cannot be used as a task requirement until content is added.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNoContentDialog(null)}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
}
@@ -16,7 +16,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Pencil, Users, ListTodo, House } from 'lucide-react';
import { Pencil, Users, ListTodo, House, TriangleAlert } from 'lucide-react';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Spinner } from '@/components/ui/spinner';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate } from '@/utils/table.util';
@@ -245,7 +251,7 @@ export default function Tasks() {
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
<DialogContent className="max-w-sm">
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
@@ -267,16 +273,43 @@ export default function Tasks() {
</Dialog>
{/* ── Single archive ────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Task"
getName={(r) => r?.name}
onArchive={(entity) => archiveTask(taskListId, entity?.task_id)}
loading={loading}
onSuccess={afterMutation}
/>
<AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}>
<AlertDialogContent className="sm:max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>Archive Task</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
Are you sure you want to archive{' '}
<span className="font-medium text-foreground">{archiveTarget?.name}</span>?
This will deactivate the record immediately.
</p>
<div className="flex items-start gap-2 rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 px-3 py-2.5 text-amber-800 dark:text-amber-300 text-sm">
<TriangleAlert className="size-4 mt-0.5 shrink-0" />
<p>
Check if users have already completed this task before archiving.
Review the <span className="font-medium">Completions</span> tab to avoid losing track of submitted work.
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={async () => {
const ok = await archiveTask(taskListId, archiveTarget?.task_id);
if (ok) { setArchiveTarget(null); afterMutation(); }
}}
>
{loading && <Spinner className="size-4 mr-2" />}
Archive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Single restore ────────────────────────────────────────────── */}
<RestoreDialog
+223 -96
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -26,6 +27,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
@@ -66,24 +68,92 @@ function SectionCard({ title, children }) {
);
}
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Category & Label", description: "Tier category, label & description" },
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
];
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const reachable = i <= maxStepReached;
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
disabled={!reachable}
className="flex flex-col items-center gap-1.5 shrink-0 group disabled:cursor-not-allowed disabled:opacity-50"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all",
reachable && "group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].filter(Boolean).join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddPlan() {
const navigate = useNavigate();
const { createPlan, loading } = useTiers();
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currentStep, setCurrentStep] = useState(0);
const [maxStepReached, setMaxStepReached] = useState(0);
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [courseConflicts, setCourseConflicts] = useState(0);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
.catch(() => {})
.finally(() => setCatLoading(false));
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
@@ -99,8 +169,29 @@ export default function AddPlan() {
// Reset picker when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
setCourseConflicts(0);
}, [categorySlug]);
const STEP_FIELDS = [
["tier_category_id", "label", "description"],
["duration_value", "duration_unit", "price", "currency"],
[],
];
const handleNext = async () => {
const valid = await trigger(STEP_FIELDS[currentStep]);
if (!valid) return;
const next = Math.min(currentStep + 1, STEPS.length - 1);
setCurrentStep(next);
setMaxStepReached((s) => Math.max(s, next));
};
// Only allow jumping via the indicator to steps already reached through Next —
// prevents landing on "Assigned Courses" before a category is picked.
const handleStepClick = (i) => {
if (i <= maxStepReached) setCurrentStep(i);
};
const onSubmit = async (values) => {
const result = await createPlan(values);
if (!result) return;
@@ -139,115 +230,151 @@ export default function AddPlan() {
</div>
</div>
<StepIndicator steps={STEPS} current={currentStep} maxStepReached={maxStepReached} onStepClick={handleStepClick} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Plan Details">
{/* ── Step 0: Category & Label ── */}
{currentStep === 0 && (
<SectionCard title="Category & Label" description="Which tier category this plan belongs to, and how it's presented.">
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
</SectionCard>
)}
{categorySlug && (
<SectionCard title="Assigned Courses">
{/* ── Step 1: Duration & Pricing ── */}
{currentStep === 1 && (
<SectionCard title="Duration & Pricing" description="How long the plan lasts and what it costs.">
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
)}
{/* ── Step 2: Assigned Courses ── */}
{currentStep === 2 && (
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
onConflictsChange={setCourseConflicts}
/>
</SectionCard>
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
disabled={loading}
>
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext} disabled={catLoading}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
</Button>
)}
</div>
</form>
+16 -3
View File
@@ -17,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
const DURATION_UNITS = [
@@ -27,6 +28,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function durationDaysToValue(days, unit) {
@@ -82,6 +84,8 @@ export default function EditPlan() {
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [courseConflicts, setCourseConflicts] = useState(0);
const [currencies, setCurrencies] = useState([]);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
const [impactLoading, setImpactLoading] = useState(false);
@@ -93,6 +97,9 @@ export default function EditPlan() {
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, [planId]);
useEffect(() => {
@@ -250,8 +257,12 @@ export default function EditPlan() {
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} {...register("currency")} />
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
@@ -275,6 +286,8 @@ export default function EditPlan() {
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setCourseConflicts}
/>
) : (
<div className="space-y-3">
@@ -290,7 +303,7 @@ export default function EditPlan() {
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading}>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
@@ -14,7 +14,7 @@ import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
export const BADGE_ICON_OPTIONS = [
// Prestige / rank
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
@@ -1,525 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Globe, House, Plus, Trash2, Loader2, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
</div>
);
}
const EMPTY_FORM = { currency: "", price: "" };
// ─── Rate-hint helpers ────────────────────────────────────────────────────────
const LOWER_HARD = 0.70;
const LOWER_WARN = 0.85;
const UPPER_WARN = 1.50;
const UPPER_HARD = 3.00;
function computeZone(price, hint) {
if (!hint || !price || Number(price) === 0) return null;
const n = Number(price);
if (isNaN(n)) return null;
if (n < hint.hardMin || n > hint.hardMax) return "block";
if (n < hint.warnMin || n > hint.warnMax) return "warn";
return "pass";
}
const ZONE_INPUT = {
block: "border-red-400 focus-visible:ring-red-400",
warn: "border-yellow-400 focus-visible:ring-yellow-400",
pass: "border-green-400 focus-visible:ring-green-400",
};
const ZONE_MSG = {
block: (h, c) => `Outside acceptable range: ${h.hardMin.toFixed(2)} – ${h.hardMax.toFixed(2)} ${c}`,
warn: (h, c) => `Outside suggested range: ${h.warnMin.toFixed(2)} – ${h.warnMax.toFixed(2)} ${c}. Will save with caution.`,
pass: () => `Price looks good.`,
};
const ZONE_TEXT = { block: "text-red-500", warn: "text-yellow-600", pass: "text-green-600" };
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function LocalizedPrices() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading, plans, fetchPlans } = useTiers();
const [prices, setPrices] = useState([]);
const [pricesLoading, setPricesLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
// When accessed from toolbar (no planId), show plan picker first
const [selectedPlanId, setSelectedPlanId] = useState(planId ?? "");
const [addForm, setAddForm] = useState(EMPTY_FORM);
const [showAdd, setShowAdd] = useState(false);
const [adding, setAdding] = useState(false);
// Inline edit state: { [currency]: price }
const [editingRow, setEditingRow] = useState(null); // currency string
const [editPrice, setEditPrice] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [removingCurrency, setRemovingCurrency] = useState(null);
// Rate hint for the add form
const [rateHint, setRateHint] = useState(null);
const [rateHintLoading, setRateHintLoading] = useState(false);
// Rate hint for inline edit
const [editRateHint, setEditRateHint] = useState(null);
const activePlanId = planId ?? selectedPlanId;
const activePlan = plan?.plan_id === Number(activePlanId) ? plan
: plans.find((p) => String(p.plan_id) === String(activePlanId));
// ─── Load ──────────────────────────────────────────────────────────────────
const loadPrices = useCallback(async (id) => {
if (!id) return;
setPricesLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${id}/prices`);
setPrices(data.data ?? []);
} catch {
toast.error("Could not load localized prices.");
} finally {
setPricesLoading(false);
}
}, []);
useEffect(() => {
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
useEffect(() => {
if (!plans.length) fetchPlans();
}, []);
useEffect(() => {
if (!activePlanId) return;
fetchPlan(activePlanId);
loadPrices(activePlanId);
}, [activePlanId]);
// Fetch rate when currency is selected in the add form
useEffect(() => {
if (!addForm.currency || !activePlan) { setRateHint(null); return; }
setRateHintLoading(true);
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${addForm.currency}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[addForm.currency];
if (!rate) { setRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setRateHint(null))
.finally(() => setRateHintLoading(false));
}, [addForm.currency, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch rate when opening an inline edit row
useEffect(() => {
if (!editingRow || !activePlan) { setEditRateHint(null); return; }
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${editingRow}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[editingRow];
if (!rate) { setEditRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setEditRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setEditRateHint(null));
}, [editingRow, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Actions ───────────────────────────────────────────────────────────────
const usedCurrencies = new Set(prices.map((p) => p.currency));
const availableCurrencies = currencies.filter(
(c) => !usedCurrencies.has(c.code) && c.code !== activePlan?.currency
);
const handleAdd = async () => {
if (!addForm.currency) { toast.error("Select a currency."); return; }
if (!addForm.price || Number(addForm.price) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(addForm.price, rateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range. Adjust it before saving."); return; }
setAdding(true);
try {
const res = await api.post(`/admin/tiers/${activePlanId}/prices`, {
currency: addForm.currency,
price: Number(addForm.price),
});
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Localized price added.");
setAddForm(EMPTY_FORM);
setShowAdd(false);
setRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not add price.");
} finally {
setAdding(false);
}
};
const handleEditSave = async (currency) => {
if (editPrice === "" || Number(editPrice) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(editPrice, editRateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range."); return; }
setSavingEdit(true);
try {
const res = await api.put(`/admin/tiers/${activePlanId}/prices/${currency}`, { price: Number(editPrice) });
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Price updated.");
setEditingRow(null);
setEditRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not update price.");
} finally {
setSavingEdit(false);
}
};
const handleRemove = async (currency) => {
setRemovingCurrency(currency);
try {
await api.delete(`/admin/tiers/${activePlanId}/prices/${currency}`);
toast.success(`${currency} price removed.`);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not remove price.");
} finally {
setRemovingCurrency(null);
}
};
// ─── Render ────────────────────────────────────────────────────────────────
const isLoading = planLoading || pricesLoading;
// ── Plan picker (toolbar entry, no planId in URL) ──────────────────────────
if (!planId) {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Localized Prices - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground">Select a plan to manage its currency overrides.</p>
</div>
</div>
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="space-y-1.5">
<Label>Plan</Label>
<Select value={selectedPlanId} onValueChange={setSelectedPlanId}>
<SelectTrigger>
<SelectValue placeholder="Select a plan…" />
</SelectTrigger>
<SelectContent>
{plans.filter((p) => !p.deletedAt).map((p) => (
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedPlanId && (
<Button onClick={() => navigate(`/admin/tiers/plans/${selectedPlanId}/prices`)}>
Manage Prices →
</Button>
)}
</div>
</div>
</div>
</section>
);
}
// ── Per-plan management ────────────────────────────────────────────────────
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={activePlan ? `Localized Prices — ${activePlan.label} - STARR` : "Localized Prices - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: activePlan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground capitalize">
{activePlan?.tier} — {activePlan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
) : (
<SectionCard
icon={Globe}
title="Currency Overrides"
description={`Base price is ${activePlan?.currency ?? "USD"} ${Number(activePlan?.price ?? 0).toFixed(2)}. Overrides take priority when a user's preferred currency matches.`}
>
{/* ── Existing prices ─────────────────────────────────────── */}
{prices.length > 0 ? (
<div className="space-y-2">
{prices.map((entry) => {
const isEditing = editingRow === entry.currency;
const currencyMeta = currencies.find((c) => c.code === entry.currency);
return (
<div
key={entry.currency}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{entry.currency}
</Badge>
{currencyMeta && (
<span className="text-xs text-muted-foreground shrink-0">
{currencyMeta.name}
</span>
)}
{isEditing ? (
<div className="flex flex-col gap-0.5">
{(() => {
const zone = computeZone(editPrice, editRateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
className={`h-7 w-28 text-sm ${zone ? ZONE_INPUT[zone] : ""}`}
value={editPrice}
autoFocus
onChange={(e) => setEditPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(entry.currency);
if (e.key === "Escape") { setEditingRow(null); setEditRateHint(null); }
}}
/>
{editRateHint && (
<p className="text-[10px] text-muted-foreground">
Good: {editRateHint.warnMin.toFixed(2)} – {editRateHint.warnMax.toFixed(2)}
</p>
)}
{zone && editPrice && (
<p className={`text-[10px] ${ZONE_TEXT[zone]}`}>
{zone === "block" ? "Out of range" : zone === "warn" ? "Caution" : ""}
</p>
)}
</>
);
})()}
</div>
) : (
<span className="text-sm font-semibold tabular-nums">
{Number(entry.price).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-green-600 hover:text-green-500"
disabled={savingEdit}
onClick={() => handleEditSave(entry.currency)}
>
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(null); setEditRateHint(null); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(entry.currency); setEditPrice(String(entry.price)); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
disabled={removingCurrency === entry.currency}
onClick={() => handleRemove(entry.currency)}
>
{removingCurrency === entry.currency
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Globe className="size-4 shrink-0" />
No localized prices yet. All users see the base price.
</div>
)}
{/* ── Add form ────────────────────────────────────────────── */}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">Add Localized Price</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Currency <span className="text-destructive">*</span></Label>
<Select
value={addForm.currency}
onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Price <span className="text-destructive">*</span></Label>
{(() => {
const zone = computeZone(addForm.price, rateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={addForm.price}
className={zone ? ZONE_INPUT[zone] : ""}
onChange={(e) => setAddForm((f) => ({ ...f, price: e.target.value }))}
/>
{rateHintLoading && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Fetching rate…
</p>
)}
{rateHint && !rateHintLoading && (
<p className="text-xs text-muted-foreground">
1 {activePlan.currency} ≈ {rateHint.rate.toFixed(4)} {addForm.currency}
{" · "}Good range: {rateHint.warnMin.toFixed(2)} – {rateHint.warnMax.toFixed(2)}
</p>
)}
{zone && addForm.price && (
<p className={`text-xs ${ZONE_TEXT[zone]}`}>
{ZONE_MSG[zone]?.(rateHint, addForm.currency)}
</p>
)}
</>
);
})()}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button
variant="outline" size="sm"
onClick={() => { setShowAdd(false); setAddForm(EMPTY_FORM); }}
disabled={adding}
>
Cancel
</Button>
<Button size="sm" onClick={handleAdd} disabled={adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
Add Price
</Button>
</div>
</div>
) : (
<Button
variant="outline" size="sm"
onClick={() => setShowAdd(true)}
disabled={availableCurrencies.length === 0}
>
<Plus className="h-4 w-4 mr-1" />
{availableCurrencies.length === 0 ? "All currencies configured" : "Add Currency"}
</Button>
)}
</SectionCard>
)}
</div>
</div>
</section>
);
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, Tag, ShieldCheck, Plus, Trash2, Loader2, Globe, ExternalLink } from "lucide-react";
import { ArrowLeft, House, Tag, ShieldCheck, Plus, Trash2, Loader2 } from "lucide-react";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -63,9 +63,6 @@ export default function PaymentPolicy() {
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
// ── Localized prices (for notice in promo section)
const [localizedPrices, setLocalizedPrices] = useState([]);
// ─── Load ────────────────────────────────────────────────────────────────────
useEffect(() => {
@@ -87,9 +84,6 @@ export default function PaymentPolicy() {
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
// ─── Save ────────────────────────────────────────────────────────────────────
@@ -182,24 +176,6 @@ export default function PaymentPolicy() {
) : (
<div className="space-y-5">
{/* ── Currency notice ───────────────────────────────────────── */}
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Plan base currency: <span className="font-mono">{plan?.currency ?? "USD"}</span>
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Flat promo code discounts are applied in <b>{plan?.currency ?? "USD"}</b>. If you need currency-specific pricing, configure overrides via{" "}
<b>Localized Prices</b> from the tier plan lists.
</p>
</div>
</div>
</div>
{/* ── Refund Policy ─────────────────────────────────────────── */}
<SectionCard
icon={ShieldCheck}
@@ -262,30 +238,6 @@ export default function PaymentPolicy() {
title="Promo Codes"
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
>
{/* Localized price notice */}
{localizedPrices.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
<Globe className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
<p className="font-semibold">
This plan has {localizedPrices.length} localized price{localizedPrices.length > 1 ? "s" : ""} set
{" "}({localizedPrices.map((p) => p.currency).join(", ")}).
</p>
<p>
Flat discounts are deducted in the currency the user is being charged — not converted from <b>{plan?.currency ?? "USD"}</b>.
The Currency select below only shows available currencies for this plan.
Use <b>percent</b> for consistent savings across all currencies.
{" "}<a
href={`/admin/tiers/plans/${planId}/prices`}
className="inline-flex items-center gap-0.5 underline underline-offset-2 font-medium"
>
Manage prices <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
</div>
)}
{/* Existing rules */}
{promoRules.length > 0 ? (
<div className="space-y-2">
@@ -25,54 +25,7 @@ export default function PlanList() {
</div>
<div className="w-full">
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<Globe className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Localized prices are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have currency-specific prices for international users (e.g. CNY, EUR, JPY). Select <b>"Localized Prices"</b>. Users without a localized price fall back to the plan's base price. If no localized price is set, the price will be displayed in <b>US Dollar (USD)</b>.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Globe /> Localized Prices (per currency)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Falls back to base price
</Badge>
</div>
</div>
</div>
</div>
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Payment Policies are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have its own set of promo codes and refund window.
Open a plan's row actions and select <b>"Promo codes (flat or percent discount)"</b> to configure it.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Tag /> Promo codes (flat or percent discount)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Refund window (minutes / hours / days)
</Badge>
</div>
</div>
</div>
</div>
<TierPlansTable />
</div>
+533 -146
View File
@@ -1,18 +1,27 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
const STATUS_BADGE = { true: "default", false: "secondary" };
// ─── Shared helpers ────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
@@ -25,12 +34,15 @@ function InfoRow({ label, children }) {
);
}
function SectionCard({ icon: Icon, title, children }) {
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
@@ -38,22 +50,13 @@ function SectionCard({ icon: Icon, title, children }) {
);
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-8 w-64" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
function formatDuration(days, unit) {
if (!days) return `${days} days`;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? 'day';
return `${value} ${label}${value !== 1 ? 's' : ''}`;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
function formatCourseDuration(seconds = 0) {
@@ -65,17 +68,452 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
const { fmtDateTime } = useDateFormat();
if (loading && !plan) {
return (
<div className="space-y-5">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
if (!plan) return <p className="text-sm text-muted-foreground">Plan not found.</p>;
return (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => {
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
})()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">{plan.currency} {Number(plan.price).toFixed(2)}</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
<InfoRow label="Updated At">{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}</InfoRow>
</div>
</SectionCard>
</div>
);
}
// ─── Tab: Payment Policy ───────────────────────────────────────────────────────
const EMPTY_PROMO = { code: "", type: "flat", value: "", currency: "USD", max_discount: "", max_uses: "", expires_at: "", min_amount: "" };
const WINDOW_UNITS = ["minutes", "hours", "days"];
function PaymentPolicyTab({ planId, plan }) {
const [policyLoading, setPolicyLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [refundAllowed, setRefundAllowed] = useState(true);
const [refundWindowValue, setRefundWindowValue] = useState(5);
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
const [promoRules, setPromoRules] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
const [localizedPrices, setLocalizedPrices] = useState([]);
useEffect(() => {
setPolicyLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
.then(({ data }) => {
const p = data.data;
if (p) {
const rp = p.refund_policy ?? {};
setRefundAllowed(rp.allowed ?? true);
setRefundWindowValue(rp.window_value ?? 5);
setRefundWindowUnit(rp.window_unit ?? "minutes");
setRefundReasonReqd(rp.reason_required ?? false);
setPromoRules(p.promo_rules ?? []);
}
})
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/admin/tier-policies/plans/${planId}/payment-policy`, {
refund_policy: {
allowed: refundAllowed,
window_value: Number(refundWindowValue),
window_unit: refundWindowUnit,
reason_required: refundReasonReqd,
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
} finally {
setSaving(false);
}
};
const handleAddPromo = () => {
const code = addForm.code.trim().toUpperCase();
if (!code) { toast.error("Code is required."); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; }
const rule = {
code,
type: addForm.type,
value: Number(addForm.value),
...(addForm.type === "flat" && addForm.currency ? { currency: addForm.currency.trim().toUpperCase() } : {}),
...(addForm.type === "percent" && addForm.max_discount ? { max_discount: Number(addForm.max_discount) } : {}),
...(addForm.max_uses ? { max_uses: Number(addForm.max_uses) } : {}),
...(addForm.expires_at ? { expires_at: addForm.expires_at } : {}),
...(addForm.min_amount ? { min_amount: Number(addForm.min_amount) } : {}),
};
setPromoRules((prev) => [...prev, rule]);
setAddForm(EMPTY_PROMO);
setShowAdd(false);
};
if (policyLoading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
return (
<div className="space-y-5">
<SectionCard
icon={ShieldCheck}
title="Refund Policy"
description="Controls whether and how long after purchase a user can request a refund."
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Allow Refunds</p>
<p className="text-xs text-muted-foreground">Users can request a refund within the window below.</p>
</div>
<Switch checked={refundAllowed} onCheckedChange={setRefundAllowed} />
</div>
{refundAllowed && (
<>
<div className="space-y-1.5">
<Label>Refund Window</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
className="w-28"
value={refundWindowValue}
onChange={(e) => setRefundWindowValue(e.target.value)}
placeholder="5"
/>
<Select value={refundWindowUnit} onValueChange={setRefundWindowUnit}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{WINDOW_UNITS.map((u) => (
<SelectItem key={u} value={u} className="capitalize">{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Users have {refundWindowValue || "?"} {refundWindowUnit} from payment to request a refund.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Require Reason</p>
<p className="text-xs text-muted-foreground">User must provide a reason when requesting a refund.</p>
</div>
<Switch checked={refundReasonReqd} onCheckedChange={setRefundReasonReqd} />
</div>
</>
)}
</div>
</SectionCard>
<SectionCard
icon={Tag}
title="Promo Codes"
description="Define discount codes users can apply at checkout."
>
{promoRules.length > 0 ? (
<div className="space-y-2">
{promoRules.map((rule) => (
<div key={rule.code} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-sm font-semibold tracking-wide">{rule.code}</code>
<Badge variant="outline" className="text-xs capitalize shrink-0">{rule.type}</Badge>
<span className="text-sm text-muted-foreground shrink-0">
{rule.type === "flat"
? `${rule.currency ?? "USD"} ${Number(rule.value).toFixed(2)} off`
: `${rule.value}% off${rule.max_discount ? ` (max ${rule.max_discount})` : ""}`}
</span>
{rule.max_uses && (
<span className="text-xs text-muted-foreground shrink-0">· {rule.max_uses} uses max</span>
)}
{rule.expires_at && (
<span className="text-xs text-muted-foreground shrink-0">
· expires {new Date(rule.expires_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => setPromoRules((prev) => prev.filter((r) => r.code !== rule.code))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Tag className="size-4 shrink-0" />
No promo codes configured for this plan.
</div>
)}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">New Promo Code</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Code <span className="text-destructive">*</span></Label>
<Input
placeholder="e.g. SAVE10"
value={addForm.code}
onChange={(e) => setAddForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
/>
</div>
<div className="space-y-1.5">
<Label>Type <span className="text-destructive">*</span></Label>
<Select value={addForm.type} onValueChange={(v) => setAddForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="flat">Flat (fixed amount off)</SelectItem>
<SelectItem value="percent">Percent (% off)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{addForm.type === "flat" ? "Amount Off" : "Percent Off"} <span className="text-destructive">*</span></Label>
<Input
type="number" step="0.01" min="0.01"
placeholder={addForm.type === "flat" ? "10.00" : "20"}
value={addForm.value}
onChange={(e) => setAddForm((f) => ({ ...f, value: e.target.value }))}
/>
</div>
{addForm.type === "flat" && (
<div className="space-y-1.5">
<Label>Currency</Label>
<Select value={addForm.currency} onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={plan?.currency ?? "USD"}>{plan?.currency ?? "USD"} — Base price</SelectItem>
{localizedPrices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency} — Localized price</SelectItem>
))}
</SelectContent>
</Select>
{localizedPrices.length === 0 && (
<p className="text-xs text-muted-foreground">No localized prices set — only base currency available.</p>
)}
</div>
)}
{addForm.type === "percent" && (
<div className="space-y-1.5">
<Label>Max Discount Cap</Label>
<Input
type="number" step="0.01" placeholder="50.00 (optional)"
value={addForm.max_discount}
onChange={(e) => setAddForm((f) => ({ ...f, max_discount: e.target.value }))}
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Max Uses</Label>
<Input
type="number" min="1" placeholder="Unlimited"
value={addForm.max_uses}
onChange={(e) => setAddForm((f) => ({ ...f, max_uses: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label>Expires At</Label>
<DateTimePicker
value={addForm.expires_at || null}
onChange={(iso) => setAddForm((f) => ({ ...f, expires_at: iso ?? "" }))}
placeholder="No expiry"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Minimum Purchase Amount</Label>
<Input
type="number" step="0.01" placeholder="No minimum"
value={addForm.min_amount}
onChange={(e) => setAddForm((f) => ({ ...f, min_amount: e.target.value }))}
/>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setAddForm(EMPTY_PROMO); }}>
Cancel
</Button>
<Button size="sm" onClick={handleAddPromo}>
<Plus className="h-4 w-4 mr-1" /> Add Code
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => { setAddForm((f) => ({ ...f, currency: plan?.currency ?? "USD" })); setShowAdd(true); }}
>
<Plus className="h-4 w-4 mr-1" /> Add Promo Code
</Button>
)}
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Policy
</Button>
</div>
</div>
);
}
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
function PaymentsTab({ planId }) {
const { fetchPayments } = useTiers();
useEffect(() => {
fetchPayments({ filters: [{ field: "plan_id", value: planId }] });
}, [planId]);
return <PaymentsTable planId={planId} />;
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Plan Details", icon: CreditCard },
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
{ key: "payments", label: "Payments", icon: Receipt },
];
// ─── Page ──────────────────────────────────────────────────────────────────────
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
const [activeTab, setActiveTab] = useState("details");
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
useEffect(() => {
fetchPlan(planId);
@@ -88,30 +526,31 @@ export default function ViewPlan() {
}, [planId]);
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: plan?.label ?? `Plan #${planId}` },
]} />
</div>
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Plan Details</h1>
<p className="text-sm text-muted-foreground">View plan information.</p>
</div>
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<CreditCard className="h-5 w-5 text-muted-foreground" />
View Plan
</h1>
{plan && (
<p className="text-sm text-muted-foreground capitalize">
{plan.tier} — {plan.label}
</p>
)}
</div>
<div className="flex items-center gap-2">
{activeTab === "details" && (
<Button
variant="outline"
size="sm"
@@ -119,107 +558,55 @@ export default function ViewPlan() {
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
Edit Plan
</Button>
</div>
)}
</div>
{loading && !plan ? (
<LoadingSkeleton />
) : !plan ? (
<p className="text-sm text-muted-foreground">Plan not found.</p>
) : (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
)}
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
</section>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
{activeTab === "payments" ? (
<div className="pb-16">
<PaymentsTab planId={planId} />
</div>
) : (
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{activeTab === "details" && (
<PlanDetailsTab
plan={plan}
loading={loading}
tierMap={tierMap}
assignedCourses={assignedCourses}
coursesLoading={coursesLoading}
/>
)}
{activeTab === "policy" && (
<PaymentPolicyTab planId={planId} plan={plan} />
)}
</div>
)}
</div>
</div>
);
}
}
+56 -3
View File
@@ -92,7 +92,6 @@ import ViewPayment from '../pages/tiers/ViewPayment';
import TierCategories from '../pages/tiers/TierCategories';
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
import LocalizedPrices from '../pages/tiers/LocalizedPrices';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
@@ -103,6 +102,23 @@ import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
// Achievements
import Achievements from '../pages/achievements/Achievements'
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
// Email Templates
import EmailTemplates from '../pages/email_templates/EmailTemplates'
import AddEmailTemplate from '../pages/email_templates/AddEmailTemplate'
import EditEmailTemplate from '../pages/email_templates/EditEmailTemplate'
import EmailBroadcasts from '../pages/email_templates/EmailBroadcasts'
// Notification Broadcasts
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
import NotificationSettings from '../pages/notifications/NotificationSettings'
// Activity
import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage'
@@ -259,10 +275,8 @@ export const AdminRoutes = {
{ path: ':planId/view', element: <ViewPlan /> },
{ path: ':planId/edit', element: <EditPlan /> },
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
{ path: ':planId/prices', element: <LocalizedPrices /> },
]
},
{ path: 'prices', element: <LocalizedPrices /> },
{ path: 'system-badges', element: <SystemBadges /> },
{
path: 'categories',
@@ -301,6 +315,45 @@ export const AdminRoutes = {
]
},
// Achievements
{
path: 'achievements',
element: <Outlet />,
children: [
{ index: true, element: <Achievements /> },
{ path: 'add', element: <AddAchievement /> },
{ path: ':id/edit', element: <EditAchievement /> },
]
},
// Email Templates
{
path: 'email-templates',
element: <Outlet />,
children: [
{ index: true, element: <EmailTemplates /> },
{ path: 'add', element: <AddEmailTemplate /> },
{ path: ':id/edit', element: <EditEmailTemplate /> },
]
},
{ path: 'email-broadcasts', element: <EmailBroadcasts /> },
// Notifications
{
path: 'notifications',
element: <Outlet />,
children: [
{ index: true, element: <NotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
},
// Activity Feed
{ path: 'activity', element: <ActivityFeed /> },
@@ -1,10 +1,18 @@
import { Trophy, Clock } from "lucide-react";
import { Trophy, Clock, CheckCircle2 } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
/**
* Props:
* course — { title, ... } the completed course
* course — { title, pending_certificate, certificate, ... } the completed course
*/
const CourseCompleteBlock = ({ course }) => {
const { fmtDate } = useDateFormat();
const certificate = course?.certificate ?? null;
const pendingCert = course?.pending_certificate ?? null;
const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert;
return (
<div className="max-w-2xl mx-auto">
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
@@ -25,18 +33,33 @@ const CourseCompleteBlock = ({ course }) => {
You've passed all required units and the final assessment for this course.
</p>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
</p>
{isIssued ? (
<div className="flex items-start gap-2.5 rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3 text-left">
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-400">Certificate Issued</p>
<p className="text-xs text-muted-foreground">
Issued on {fmtDate(certificate.issued_at)}. View and download it from your Certificates page.
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
{isPending
? `Certificates are issued automatically every hour. Yours will be ready by ${fmtDate(pendingCert.issue_at)} — check your notifications.`
: "Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications."}
</p>
</div>
</div>
)}
</div>
</div>
);
};
export default CourseCompleteBlock;
export default CourseCompleteBlock;
@@ -179,18 +179,13 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
);
navigate(`/course/${info.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{course.title}
</h1>
@@ -242,18 +237,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<Button
onClick={() => {
if (!info?.course_id) return;
if (taskId && groupId && taskListId) {
// Task context — read inside ViewRequirement
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
);
} else {
// No task context — fall back to standalone course reader
navigate(`/course/${info.course_id}/unit`, {
state: allRead ? { seekFirstIncomplete: true } : {},
});
}
navigate(`/course/${info.course_id}/unit`, {
state: {
...(allRead ? { seekFirstIncomplete: true } : {}),
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
disabled={done || !info?.course_id}
>
@@ -147,10 +147,16 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
return (
<div
key={lesson.id}
onClick={() => !isFetching && navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { lesson } },
)}
onClick={() => {
if (isFetching || !info?.unit?.course?.course_id) return;
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: {
lessonId: info.lesson_id,
unitId: info.unit.unit_id,
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
@@ -194,18 +194,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit } }
);
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{unit.title}
</h1>
@@ -251,10 +246,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit: selected } },
)}
onClick={() => {
const info = details[selected?.reference_id];
if (!info?.course?.course_id) return;
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
disabled={
getProgress(selected ?? {}) >= 100 ||
locked[selected?.reference_id] ||
+106 -60
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2, Globe } from "lucide-react";
import { KeyRound, CreditCard, Mail, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -20,12 +20,10 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -285,74 +283,122 @@ function NewsletterSection() {
);
}
// ─── Currency Preference ─────────────────────────────────────────────────────
// ─── Advertisements ───────────────────────────────────────────────────────────
function CurrencySection() {
const { profile, getProfile } = useProfile();
const { setCurrency } = useCurrencyPreference();
const [currencies, setCurrencies] = useState([]);
const [localValue, setLocalValue] = useState("USD");
const [saving, setSaving] = useState(false);
const AD_OPTIONS = [
{
key: "show_popup_ads",
label: "Popup ads",
description: "Show promotional popups when you open pages like the dashboard.",
},
{
key: "show_other_ads",
label: "Other ads",
description: "Show banner, hero, and sidebar advertisements across the site.",
},
];
function AdvertisementsSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
const [confirmPopupOff, setConfirmPopupOff] = useState(false);
useEffect(() => {
getProfile();
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
// Sync local value when profile loads or changes
useEffect(() => {
if (profile?.preferred_currency) setLocalValue(profile.preferred_currency);
}, [profile?.preferred_currency]);
const showPopupAds = profile?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profile?.personal_info?.show_other_ads ?? true;
// No separate stored flag — "hidden" just means both underlying toggles are off,
// so it can never drift out of sync with them.
const hideAllAds = !showPopupAds && !showOtherAds;
const savedValue = profile?.preferred_currency ?? "USD";
const isDirty = localValue !== savedValue;
const handleToggle = async (key, value) => {
// Turning popup ads off also turns off other ads — confirm first since
// it's a bigger change than the switch being flipped suggests.
if (key === "show_popup_ads" && value === false) {
setConfirmPopupOff(true);
return;
}
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
const handleSave = async () => {
setSaving(true);
try {
await api.patch("/client/profile/currency", { currency: localValue });
setCurrency(localValue);
toast.success("Currency preference saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save preference.");
setLocalValue(savedValue); // revert on error
} finally {
setSaving(false);
const confirmTurnOffPopupAndOther = async () => {
setConfirmPopupOff(false);
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
const handleHideAllToggle = async (hide) => {
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
return (
<div className="space-y-3">
<div className="space-y-1.5 max-w-xs">
<Label>Preferred currency</Label>
{!profile ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={localValue} onValueChange={setLocalValue} disabled={saving}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-xs text-muted-foreground">
Plans without localized prices always display in USD regardless of this setting.
</p>
<>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Hide all ads</p>
<p className="text-xs text-muted-foreground">
Turn off every advertisement across the platform, popups included.
</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch checked={hideAllAds} onCheckedChange={handleHideAllToggle} />
)}
</div>
<Separator className="mt-4" />
</div>
{AD_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? true}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < AD_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
{isDirty && (
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
)}
</div>
<AlertDialog open={confirmPopupOff} onOpenChange={setConfirmPopupOff}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Turn off popup ads?</AlertDialogTitle>
<AlertDialogDescription>
This will also turn off Other ads (banner, hero, and sidebar advertisements).
You can turn either back on here anytime.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmTurnOffPopupAndOther}>
Turn off both
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -446,8 +492,8 @@ export default function AccountSettings() {
<NewsletterSection />
</Section>
<Section icon={Globe} title="Currency Preference" description="Set the currency used to display plan prices across the platform.">
<CurrencySection />
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
<AdvertisementsSection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
+6 -45
View File
@@ -15,13 +15,10 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import {
ArrowLeft, BookOpen, CalendarDays, Check,
Globe, House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
function formatDuration(days, unit) {
@@ -69,8 +66,6 @@ const Checkout = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat();
const { fmtPlanPrice, resolvePlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token");
@@ -97,29 +92,11 @@ const Checkout = () => {
getProfile();
}, []);
// Seed currency from the user's stored preference when profile loads
useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
getMyTier();
if (!plans.length) getPlans();
}, [getMyTier, getPlans, plans.length]);
const handleCurrencyChange = async (newCurrency) => {
setCurrency(newCurrency);
setPromoResult(null);
setPromoCode("");
try {
await api.patch("/client/profile/currency", { currency: newCurrency });
} catch {
// silent — context + localStorage already updated
}
};
const plan = useMemo(
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
[plans, planId]
@@ -158,8 +135,9 @@ const Checkout = () => {
const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const { price: effectivePrice, currency: effectiveCurrency } = resolvePlanPrice(plan ?? {});
const subtotal = plan ? effectivePrice : 0;
const effectivePrice = plan ? Number(plan.price) : 0;
const effectiveCurrency = plan?.currency ?? 'USD';
const subtotal = effectivePrice;
const discount = promoResult?.discount ?? 0;
const total = Math.max(subtotal - discount, 0);
const duration = formatDuration(plan?.duration_days, plan?.duration_unit);
@@ -172,8 +150,7 @@ const Checkout = () => {
const handleApplyPromo = async () => {
if (!plan) return;
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase(), localeCurrency);
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
if (result?.valid) {
setPromoResult(result);
toast.success("Promo code applied.");
@@ -188,11 +165,9 @@ const Checkout = () => {
};
const handlePayPal = async () => {
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const order = await createOrder(
plan.plan_id,
promoResult?.code ?? null,
localeCurrency,
);
if (!order) return;
const approvalUrl = order.approval_url;
@@ -299,22 +274,8 @@ const Checkout = () => {
</div>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-2xl font-bold text-primary">
{fmtPlanPrice(plan)}
{fmtCurrency(effectivePrice, effectiveCurrency)}
</p>
{plan.prices?.length > 0 && (
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger className="h-8 w-28 text-xs">
<Globe className="size-3 mr-1 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={plan.currency}>{plan.currency}</SelectItem>
{plan.prices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
+64 -35
View File
@@ -25,6 +25,9 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -330,7 +333,7 @@ const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pending
<p className="text-sm font-semibold text-foreground">{courseTitle}</p>
</div>
<p>
Your certificate will be issued within <span className="font-medium text-foreground">45 minutes</span> after passing and will appear in your <span className="font-medium text-foreground">Certificates</span> page.
Your certificate will be issued within <span className="font-medium text-foreground">the hour</span> after passing and will appear in your <span className="font-medium text-foreground">Certificates</span> page.
</p>
</>
)}
@@ -503,6 +506,7 @@ const CourseDetails = () => {
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick } = useClientAdvertisements();
const [tierMap, setTierMap] = useState({});
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
@@ -526,9 +530,14 @@ const CourseDetails = () => {
getMyTier();
getCourse(courseId);
fetchCourseProgress(courseId);
getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]);
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
const bannerAd = advertisements["course_details.banner"] ?? null;
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
// Resolve badge image once course loads — issue a client stream token for
// private S3 assets so the badge preview works on this page.
useEffect(() => {
@@ -645,44 +654,64 @@ const CourseDetails = () => {
</div>
</div>
{/* Advertisement Banner */}
<div className="lg:container lg:mx-auto xs:px-6 lg:px-4">
{adLoading["course_details.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-0 lg:py-8">
<div className="flex flex-col gap-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 lg:text-lg">
<p>{course?.description ?? ""}</p>
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex flex-col gap-4 flex-1 min-w-0">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
{/* Objectives */}
{course?.objectives?.length > 0 && (
<>
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</>
)}
{/* Units */}
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">Course content</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
/>
</>
)}
</div>
{/* Objectives */}
{course?.objectives?.length > 0 && (
<>
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</>
)}
{/* Units */}
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">Course content</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
/>
</>
)}
{/* Advertisement Sidebar */}
<aside className="hidden lg:block w-72 shrink-0 sticky top-24 h-fit">
{adLoading["course_details.sidebar"] ? (
<SidebarSkeleton />
) : (
<Sidebar ad={sidebarAd} onCtaClick={handleAdCtaClick} />
)}
</aside>
</div>
</div>
</div>
+14
View File
@@ -18,6 +18,8 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -160,6 +162,7 @@ const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
@@ -180,8 +183,12 @@ const CoursesList = () => {
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => {});
getActiveAdvertisement("course_list.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bannerAd = advertisements["course_list.banner"] ?? null;
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
@@ -290,6 +297,13 @@ const CoursesList = () => {
</div>
)}
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{/* Course Grid */}
{coursesLoading ? (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
+17 -22
View File
@@ -4,7 +4,6 @@ import {
Users, Timer,
Tag, LockIcon, Check,
} from "lucide-react";
import { ThemeSwitcher } from "../components/ThemeSwitcher";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -131,13 +130,15 @@ const GroupsTable = ({ groups, onView }) => (
<TableHead className="w-10 text-center px-4">#</TableHead>
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4">Description</TableHead>
<TableHead className="px-4">Task Lists</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g, i) => {
const isDefault = g.group_code === 'NOGRP';
const taskListCount = Number(g.task_list_count ?? g.taskLists?.length ?? 0);
return (
<TableRow key={g.group_id}>
<TableCell className="text-center px-4 text-muted-foreground tabular-nums">
@@ -147,12 +148,15 @@ const GroupsTable = ({ groups, onView }) => (
<TableCell className="px-4">
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
</TableCell>
<TableCell className="px-4 text-muted-foreground">
<TableCell className="px-4 text-muted-foreground ">
{isDefault
? <span className="text-xs italic">Awaiting assignment by admin</span>
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
}
</TableCell>
<TableCell className="px-4">
{taskListCount}
</TableCell>
<TableCell className="px-4 text-right">
<Button size="sm" variant="outline" onClick={() => onView(g)}>
View
@@ -175,6 +179,7 @@ const GroupsTableSkeleton = () => (
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4 text-center whitespace-nowrap">Task Lists</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
@@ -185,6 +190,7 @@ const GroupsTableSkeleton = () => (
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-8 mx-auto" /></TableCell>
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
</TableRow>
))}
@@ -201,7 +207,7 @@ const Client = () => {
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick, dismissPopupForever } = useClientAdvertisements();
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
@@ -210,8 +216,8 @@ const Client = () => {
const userTier = myTier?.tier ?? "free";
const heroAd = advertisements.hero ?? null;
const popupAd = advertisements.popup ?? null;
const heroAd = advertisements["dashboard.hero"] ?? null;
const popupAd = advertisements["dashboard.popup"] ?? null;
// Show welcome toast on first registration
useEffect(() => {
@@ -234,24 +240,12 @@ const Client = () => {
// ── Resolve active hero + popup ads once on mount ────────────────────────
useEffect(() => {
getActiveAdvertisement("hero");
getActiveAdvertisement("popup").then((ad) => {
if (ad) setPopupOpen(true);
getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => {
if (result["dashboard.popup"]) setPopupOpen(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── CTA click — track then navigate ───────────────────────────────────────
const handleAdCtaClick = (ad, cta) => {
trackClick(ad.advertisement_id);
if (!cta?.link) return;
if (/^https?:\/\//.test(cta.link)) {
window.open(cta.link, "_blank", "noopener,noreferrer");
} else {
navigate(cta.link);
}
};
// Show only first 3
const featuredCourses = courses.slice(0, 3);
@@ -277,7 +271,7 @@ const Client = () => {
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-8 px-16">
{/* ── Hero Advertisement ── */}
{adLoading.hero ? (
{adLoading["dashboard.hero"] ? (
<HeroSkeleton />
) : (
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
@@ -342,6 +336,7 @@ const Client = () => {
open={popupOpen}
onOpenChange={setPopupOpen}
onCtaClick={handleAdCtaClick}
onDismissForever={dismissPopupForever}
/>
{/* ── Upsell Modal — only for locked courses ── */}
@@ -387,4 +382,4 @@ const Client = () => {
);
};
export default Client;
export default Client;
+3 -17
View File
@@ -1,27 +1,13 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Trophy, Shield, BookOpen, Award, BadgeCheck,
Medal, Flame, Zap, Target, Star, ArrowLeft,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { Trophy, BadgeCheck, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
const ACHIEVEMENT_ICONS = {
early_access: Star,
premium_first_time: BadgeCheck,
exclusive_first_time: Medal,
first_course_completed: BookOpen,
courses_completed_5: Flame,
courses_completed_10: Zap,
perfect_quiz_score: Target,
profile_completed: Shield,
first_referral: Award,
};
const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
export default function MyAchievements() {
@@ -71,7 +57,7 @@ export default function MyAchievements() {
) : (
<div className="rounded-xl border bg-card">
{sorted.map((item, i) => {
const Icon = ACHIEVEMENT_ICONS[item.key] ?? getFallbackIcon(item.type);
const Icon = LucideIcons[item.icon] ?? getFallbackIcon(item.type);
return (
<div key={item.achievement_id ?? i}>
<div className="flex items-center gap-4 p-4">
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Bell, LockIcon, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from "@/components/ui/pagination";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
import { NotificationIcon, timeAgo } from "@/components/generic/notificationDisplay";
import NotificationDetailDialog from "@/components/generic/NotificationDetailDialog";
import { toast } from "sonner";
const PAGE_SIZES = [10, 20, 50];
function ClearAllDialog({ open, onOpenChange, onConfirm, loading }) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all notifications</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all of your notifications. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Clear all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// Always rendered — even with 0 pages — so the footer stays visible as notifications come in.
function PaginationControls({ page, totalPages, onPage }) {
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
export default function Notifications() {
const navigate = useNavigate();
const {
notifications, unseenCount, loading, pagination,
fetchNotifications, markSeen, markAllSeen, clearAll,
} = useClientNotifications();
const [selected, setSelected] = useState(null);
const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false);
useEffect(() => { fetchNotifications(1, PAGE_SIZES[0]); }, [fetchNotifications]);
function handleClickNotification(n) {
if (!n.seen) markSeen(n.notification_id);
setSelected(n);
}
function handlePageChange(page) {
fetchNotifications(page, pagination.limit);
}
function handlePageSizeChange(value) {
fetchNotifications(1, Number(value));
}
async function handleClearAll() {
setClearing(true);
const ok = await clearAll();
setClearing(false);
setClearOpen(false);
if (ok) toast.success("All notifications cleared.");
else toast.error("Could not clear notifications.");
}
const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1;
const rangeEnd = Math.min(pagination.page * pagination.limit, pagination.total);
return (
<section className="mt-17 bg-muted min-h-full">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">Notifications</h1>
<Badge variant="outline" className="gap-1 text-xs">
<LockIcon className="h-3 w-3" /> Only you
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{unseenCount > 0 ? `${unseenCount} unread` : "You're all caught up."}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{unseenCount > 0 && (
<Button variant="outline" size="sm" onClick={markAllSeen}>
Mark all as read
</Button>
)}
<Button
variant="outline"
size="sm"
className="gap-1.5 text-destructive hover:text-destructive"
disabled={notifications.length === 0}
onClick={() => setClearOpen(true)}
>
<Trash2 className="size-3.5" />
Clear all
</Button>
</div>
</div>
<div className="rounded-2xl border bg-card overflow-hidden">
{loading && notifications.length === 0 ? (
<div className="p-4 space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-40 text-center">
<Bell className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium">No notifications yet</p>
<p className="text-xs text-muted-foreground">You'll see updates about your courses and account here.</p>
</div>
) : (
<ul>
{notifications.map((n, i) => (
<li key={n.notification_id}>
<button
onClick={() => handleClickNotification(n)}
className={cn(
"w-full text-left px-5 py-4 hover:bg-muted/50 transition-colors",
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.seen && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
<p className="text-sm font-medium truncate">{n.title}</p>
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[11px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
</div>
</div>
</button>
{i < notifications.length - 1 && <Separator />}
</li>
))}
</ul>
)}
</div>
{/* Footer stays visible even when the list is empty, so it's ready as notifications come in */}
<div className="flex items-center justify-between gap-3 flex-wrap px-1">
<p className="text-sm text-muted-foreground">
Showing {rangeStart}-{rangeEnd} of {pagination.total}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rows per page</span>
<Select value={String(pagination.limit)} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PaginationControls
page={pagination.page}
totalPages={pagination.pages}
onPage={handlePageChange}
/>
</div>
</div>
</div>
<NotificationDetailDialog
notification={selected}
onOpenChange={(isOpen) => { if (!isOpen) setSelected(null); }}
/>
<ClearAllDialog
open={clearOpen}
onOpenChange={setClearOpen}
onConfirm={handleClearAll}
loading={clearing}
/>
</section>
);
}
+15 -22
View File
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import {
Megaphone, BookOpen, Clock, Check,
BookOpen, Clock, Check,
Tag, LockIcon, Zap, RotateCcw,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
@@ -22,7 +22,8 @@ import { toast } from "sonner";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -104,7 +105,7 @@ const PlanSkeleton = () => (
const PREVIEW_COURSE_LIMIT = 2;
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtPlanPrice } = useCurrency();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
@@ -341,7 +342,8 @@ export default function PlanList() {
const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtDate } = useDateFormat();
const { fmtPlanPrice } = useCurrency();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false);
@@ -351,8 +353,12 @@ export default function PlanList() {
useEffect(() => {
getPlans();
getMyTier();
getActiveAdvertisement("plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
const bannerAd = advertisements["plans.banner"] ?? null;
useEffect(() => {
clearInterval(refundTimerRef.current);
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
@@ -397,24 +403,11 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */}
{/* <Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
<CardContent className="flex flex-col gap-4 py-20 md:flex-row md:items-center md:justify-between pl-10">
<div className="space-y-2">
<Badge>
<Megaphone /> Limited Time Offer
</Badge>
<div className="max-w-xl">
<h1 className="text-3xl font-bold line-clamp-3 leading-relaxed">
Upgrade Your Learning Journey
</h1>
<p className="mt-2 text-sm line-clamp-3 text-muted-foreground">
Unlock premium courses, certificates, and exclusive educational content.
Get access to industry-leading materials and grow your skills today.
</p>
</div>
</div>
</CardContent>
</Card> */}
{adLoading["plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{/* Section Header */}
<div className="text-center mt-6">
+2 -13
View File
@@ -10,9 +10,6 @@ import {
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -107,9 +104,8 @@ const ViewPlan = () => {
const { id } = useParams();
const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { profile, getProfile } = useProfile();
const { fmtPlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
const { getProfile } = useProfile();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
useEffect(() => {
getProfile();
@@ -117,13 +113,6 @@ const ViewPlan = () => {
if (!plans.length) getPlans();
}, [id]);
// Seed currency preference from the user's stored profile
useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
const loading = plansLoading;
+2 -2
View File
@@ -11,7 +11,6 @@ import GroupList from '../pages/GroupList'
import ViewTaskDetails from '../pages/ViewTaskDetails'
import ProfilePage from '../pages/Profile'
import Checkout from '../pages/Checkout'
import ViewRequirement from '../pages/ViewRequirement'
import EditProfile from '../pages/EditProfile'
import PlanList from '../pages/PlanList'
import ViewPlan from '../pages/ViewPlan'
@@ -20,6 +19,7 @@ import CourseCheckout from '../pages/CourseCheckout'
import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import AccountSettings from '../pages/AccountSettings'
import Notifications from '../pages/Notifications'
import IntroPage from '@/modules/auth/pages/Intro'
import { useAuth } from '@/contexts/AuthContext'
@@ -60,6 +60,7 @@ export const ClientRoutes = {
{ path: 'certificates', element: <MyCertificates /> },
{ path: 'achievements', element: <MyAchievements /> },
{ path: 'settings', element: <AccountSettings /> },
{ path: 'notifications', element: <Notifications /> },
{
path: 'plans', element: <Outlet />,
children: [
@@ -101,7 +102,6 @@ export const ClientRoutes = {
path: 'task/:taskId', element: <Outlet />,
children: [
{ index: true, element: <ViewTask /> },
{ path: 'requirement', element: <ViewRequirement />, handle: { showFooter: false } },
]
},
]