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