testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:32:00 +08:00
parent fe47bdea3c
commit 17326b2c2e
78 changed files with 4804 additions and 1054 deletions
@@ -0,0 +1,174 @@
const BADGE_GRADIENTS = {
// ── Original 12 ──────────────────────────────────────────────────────────────
purple: "linear-gradient(145deg, #a855f7cc 0%, #7e22cebf 55%, #3b0764b3 100%)",
green: "linear-gradient(145deg, #4ade80cc 0%, #15803dbf 55%, #14532db3 100%)",
rose: "linear-gradient(145deg, #fb7185cc 0%, #be123cbf 55%, #4c0519b3 100%)",
amber: "linear-gradient(145deg, #fbbf24cc 0%, #d97706bf 55%, #78350fb3 100%)",
sky: "linear-gradient(145deg, #38bdf8cc 0%, #0369a1bf 55%, #0c4a6eb3 100%)",
indigo: "linear-gradient(145deg, #818cf8cc 0%, #4338cabf 55%, #1e1b4bb3 100%)",
teal: "linear-gradient(145deg, #2dd4bfcc 0%, #0f766ebf 55%, #042f2eb3 100%)",
orange: "linear-gradient(145deg, #fb923ccc 0%, #c2410cbf 55%, #431407b3 100%)",
pink: "linear-gradient(145deg, #f472b6cc 0%, #be185dbf 55%, #500724b3 100%)",
cyan: "linear-gradient(145deg, #22d3eecc 0%, #0e7490bf 55%, #083344b3 100%)",
lime: "linear-gradient(145deg, #a3e635cc 0%, #4d7c0fbf 55%, #1a2e05b3 100%)",
slate: "linear-gradient(145deg, #94a3b8cc 0%, #475569bf 55%, #0f172ab3 100%)",
// ── Extended 18 ──────────────────────────────────────────────────────────────
red: "linear-gradient(145deg, #f87171cc 0%, #b91c1cbf 55%, #450a0ab3 100%)",
yellow: "linear-gradient(145deg, #fde047cc 0%, #a16207bf 55%, #3d1f00b3 100%)",
violet: "linear-gradient(145deg, #a78bfacc 0%, #6d28d9bf 55%, #2e1065b3 100%)",
fuchsia: "linear-gradient(145deg, #e879f9cc 0%, #a21cafbf 55%, #4a044eb3 100%)",
emerald: "linear-gradient(145deg, #34d399cc 0%, #047857bf 55%, #064e3bb3 100%)",
blue: "linear-gradient(145deg, #60a5facc 0%, #1d4ed8bf 55%, #1e3a8ab3 100%)",
zinc: "linear-gradient(145deg, #a1a1aacc 0%, #3f3f46bf 55%, #18181bb3 100%)",
stone: "linear-gradient(145deg, #d6d3d1cc 0%, #57534ebf 55%, #1c1917b3 100%)",
brown: "linear-gradient(145deg, #d97706cc 0%, #7c2d12bf 55%, #1c0902b3 100%)",
gold: "linear-gradient(145deg, #fcd34dcc 0%, #b45309bf 55%, #451a03b3 100%)",
navy: "linear-gradient(145deg, #93c5fdcc 0%, #1e40afbf 55%, #0f172ab3 100%)",
forest: "linear-gradient(145deg, #86efaccc 0%, #166534bf 55%, #052e16b3 100%)",
wine: "linear-gradient(145deg, #fda4afcc 0%, #9f1239bf 55%, #3b0014b3 100%)",
charcoal: "linear-gradient(145deg, #9ca3afcc 0%, #374151bf 55%, #111827b3 100%)",
midnight: "linear-gradient(145deg, #818cf8cc 0%, #312e81bf 55%, #0d0c1db3 100%)",
lavender: "linear-gradient(145deg, #ddd6fecc 0%, #7c3aedbf 55%, #2e1065b3 100%)",
salmon: "linear-gradient(145deg, #fca5a5cc 0%, #e11d48bf 55%, #4c0519b3 100%)",
mint: "linear-gradient(145deg, #a7f3d0cc 0%, #059669bf 55%, #022c22b3 100%)",
};
const SOLID_GRADIENTS = {
// ── Original 12 ──────────────────────────────────────────────────────────────
purple: "linear-gradient(145deg, #a855f7 0%, #7e22ce 55%, #3b0764 100%)",
green: "linear-gradient(145deg, #4ade80 0%, #15803d 55%, #14532d 100%)",
rose: "linear-gradient(145deg, #fb7185 0%, #be123c 55%, #4c0519 100%)",
amber: "linear-gradient(145deg, #fbbf24 0%, #d97706 55%, #78350f 100%)",
sky: "linear-gradient(145deg, #38bdf8 0%, #0369a1 55%, #0c4a6e 100%)",
indigo: "linear-gradient(145deg, #818cf8 0%, #4338ca 55%, #1e1b4b 100%)",
teal: "linear-gradient(145deg, #2dd4bf 0%, #0f766e 55%, #042f2e 100%)",
orange: "linear-gradient(145deg, #fb923c 0%, #c2410c 55%, #431407 100%)",
pink: "linear-gradient(145deg, #f472b6 0%, #be185d 55%, #500724 100%)",
cyan: "linear-gradient(145deg, #22d3ee 0%, #0e7490 55%, #083344 100%)",
lime: "linear-gradient(145deg, #a3e635 0%, #4d7c0f 55%, #1a2e05 100%)",
slate: "linear-gradient(145deg, #94a3b8 0%, #475569 55%, #0f172a 100%)",
// ── Extended 18 ──────────────────────────────────────────────────────────────
red: "linear-gradient(145deg, #f87171 0%, #b91c1c 55%, #450a0a 100%)",
yellow: "linear-gradient(145deg, #fde047 0%, #a16207 55%, #3d1f00 100%)",
violet: "linear-gradient(145deg, #a78bfa 0%, #6d28d9 55%, #2e1065 100%)",
fuchsia: "linear-gradient(145deg, #e879f9 0%, #a21caf 55%, #4a044e 100%)",
emerald: "linear-gradient(145deg, #34d399 0%, #047857 55%, #064e3b 100%)",
blue: "linear-gradient(145deg, #60a5fa 0%, #1d4ed8 55%, #1e3a8a 100%)",
zinc: "linear-gradient(145deg, #a1a1aa 0%, #3f3f46 55%, #18181b 100%)",
stone: "linear-gradient(145deg, #d6d3d1 0%, #57534e 55%, #1c1917 100%)",
brown: "linear-gradient(145deg, #d97706 0%, #7c2d12 55%, #1c0902 100%)",
gold: "linear-gradient(145deg, #fcd34d 0%, #b45309 55%, #451a03 100%)",
navy: "linear-gradient(145deg, #93c5fd 0%, #1e40af 55%, #0f172a 100%)",
forest: "linear-gradient(145deg, #86efac 0%, #166534 55%, #052e16 100%)",
wine: "linear-gradient(145deg, #fda4af 0%, #9f1239 55%, #3b0014 100%)",
charcoal: "linear-gradient(145deg, #9ca3af 0%, #374151 55%, #111827 100%)",
midnight: "linear-gradient(145deg, #818cf8 0%, #312e81 55%, #0d0c1d 100%)",
lavender: "linear-gradient(145deg, #ddd6fe 0%, #7c3aed 55%, #2e1065 100%)",
salmon: "linear-gradient(145deg, #fca5a5 0%, #e11d48 55%, #4c0519 100%)",
mint: "linear-gradient(145deg, #a7f3d0 0%, #059669 55%, #022c22 100%)",
};
export default function CourseBadge({ title = "Course Title", level, color = "purple", imageUrl, mini = false }) {
const levelLabel = level
? `${level.charAt(0).toUpperCase()}${level.slice(1)} Level`
: null;
// ── Mini variant: just the gradient + logo mark, no text/line ──────────────
if (mini) {
return (
<div className="relative w-12 h-12 rounded-xl overflow-hidden flex-shrink-0 select-none shadow-md">
{imageUrl ? (
<>
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div
className="absolute inset-0"
style={{ background: BADGE_GRADIENTS[color] ?? BADGE_GRADIENTS.purple }}
/>
</>
) : (
<div
className="absolute inset-0"
style={{ background: SOLID_GRADIENTS[color] ?? SOLID_GRADIENTS.purple }}
/>
)}
{/* HIDE OUR LOGO */}
{/* <div className="relative h-full flex items-center justify-center">
<img src="/philpro-white-single.png" alt="Philpro" className="h-5 w-5 object-contain drop-shadow" />
</div> */}
</div>
);
}
return (
<div className="relative w-44 h-48 rounded-2xl overflow-hidden flex-shrink-0 select-none shadow-lg">
{/* ── Layer 1: Background — image when set, solid gradient otherwise ── */}
{imageUrl ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
) : (
<div
className="absolute inset-0"
style={{ background: SOLID_GRADIENTS[color] ?? SOLID_GRADIENTS.purple }}
/>
)}
{/* ── Layer 2: Gradient colour overlay on top of the image ── */}
{imageUrl && (
<div
className="absolute inset-0"
style={{ background: BADGE_GRADIENTS[color] ?? BADGE_GRADIENTS.purple }}
/>
)}
{/* ── Layer 3: Soft glow in the upper-right corner (always present) ── */}
<div
className="absolute -top-6 -right-6 w-28 h-28 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, rgba(255,255,255,0.15), transparent 70%)",
}}
/>
{/* ── Layer 4: Content ── */}
<div className="relative flex flex-col justify-between h-full p-3.5">
<div className="flex-1 flex flex-col justify-center gap-1.5 pr-2">
<p className="text-white font-bold text-sm leading-snug line-clamp-4 drop-shadow">
{title}
</p>
{levelLabel && (
<p className="text-white/60 text-[9.5px] font-semibold uppercase tracking-widest drop-shadow">
{levelLabel}
</p>
)}
</div>
<div>
<div className="border-t border-white/20 mb-2" />
<div className="flex items-center justify-between gap-2">
<img
src="/philpro-white-single.png"
alt="Philpro"
className="h-5 object-contain drop-shadow"
/>
<span className="text-white/75 text-[8.5px] font-bold uppercase tracking-[0.12em] text-right leading-tight drop-shadow">
Course<br />Completion
</span>
</div>
</div>
</div>
</div>
);
}
@@ -78,7 +78,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
@@ -110,7 +110,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
<Separator />
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
<ScrollArea className="flex-1 min-h-0 pr-2">
<ScrollArea className="max-h-[50vh] pr-2">
{detailLoading && !breakdown ? (
<div className="space-y-3 py-1">
{[...Array(4)].map((_, i) => (
@@ -0,0 +1,140 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTiers } from "@/contexts/AdminTiersContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/archive/columns.config";
import { buildToolbarActions } from "../../config/tiers/plans/archive/toolbar.config";
import { buildSelectionActions } from "../../config/tiers/plans/archive/selection.config";
import { buildRowActions } from "../../config/tiers/plans/archive/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function ArchivedTierPlansTable() {
const navigate = useNavigate();
const {
plans, planAttributes, planPagination, setPlanPagination,
loading, fetchPlans, restorePlan, bulkRestorePlans,
} = useTiers();
const [restoreTarget, setRestoreTarget] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
tableInstance: null,
});
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
const fetchArchived = useCallback(
(params) => fetchPlans({ ...params, archived: true }),
[fetchPlans]
);
const exportConfig = useMemo(() => ({
allData: plans,
attributes: planAttributes,
filename: `${getTimestamp()}_ArchivedTierPlans`,
sheetName: "Archived Tier Plans",
}), [plans, planAttributes]);
const rowActions = buildRowActions({
navigate,
onRestore: (row) => setRestoreTarget(row),
});
const toolbarActions = buildToolbarActions({
fetchPlans: fetchArchived,
pagination: planPagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
onRestore: (row) => setRestoreTarget(row),
onRestoreMany: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(planAttributes, rowActions),
[planAttributes, rowActions]
);
const handleRestoreSuccess = () => {
setRestoreTarget(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
fetchArchived({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
return (
<>
<DataTable
title="Archived Plans"
data={plans}
columns={columns}
attributes={planAttributes}
pagination={planPagination}
setPagination={setPlanPagination}
loading={loading}
onFetch={fetchArchived}
onFetchFilterData={async () => []}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="plan"
emptyMessage="No archived plans found."
/>
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onRestore={(entity) => restorePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Plan"
onRestore={({ ids }) => bulkRestorePlans(ids)}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
</>
);
}
@@ -1,33 +1,69 @@
import { useEffect, useState, useMemo } from "react";
import { BookOpen, Check, RotateCcw } from "lucide-react";
import { useState, useEffect, useMemo } from "react";
import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
* CoursePicker
*
* Props:
* subscription — tier slug to filter courses (e.g. "premium"). Pass null/undefined to hide.
* selectedIds — Set<string> of selected course_id strings
* subscription — tier slug ("premium"). Null/undefined = hidden.
* selectedIds — Set<string> of selected course_id strings (managed by parent)
* onChange — (Set<string>) => void
* 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).
*
* 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.
*/
export function CoursePicker({ subscription, selectedIds, onChange }) {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false }) {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false);
const [bundleAll, setBundleAll] = useState(true);
const [popoverOpen, setPopoverOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!subscription) { setCourses([]); return; }
if (!subscription) { setCourses([]); setBundleAll(true); return; }
setLoading(true);
setSearch("");
setBundleAll(true); // reset question to "Yes" whenever subscription changes
api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`)
.then(({ data }) => setCourses(data.data ?? []))
.then(({ data }) => {
const loaded = data.data ?? [];
setCourses(loaded);
if (!isPreloaded) {
// AddPlan: bundle all by default
setBundleAll(true);
onChange(new Set(loaded.map((c) => String(c.course_id))));
} else {
// EditPlan: CoursePicker mounts only after assignments loaded into selectedIds.
// Detect initial mode from current selectedIds vs total courses.
const size = selectedIds.size;
if (size > 0 && size < loaded.length) {
// Partial selection saved previously → specific mode
setBundleAll(false);
} else {
// All selected, or none (no courses assigned yet) → bundle all
setBundleAll(true);
onChange(new Set(loaded.map((c) => String(c.course_id))));
}
}
})
.catch(() => setCourses([]))
.finally(() => setLoading(false));
}, [subscription]);
}, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const q = search.toLowerCase();
@@ -46,100 +82,170 @@ export function CoursePicker({ subscription, selectedIds, onChange }) {
onChange(next);
};
const checkAll = () => onChange(new Set(filtered.map((c) => String(c.course_id))));
const resetAll = () => onChange(new Set());
const checkAll = () => onChange(new Set(courses.map((c) => String(c.course_id))));
const resetAll = () => onChange(new Set());
// "Yes, include all" clicked
const handleBundleAll = () => {
setBundleAll(true);
setPopoverOpen(false);
onChange(new Set(courses.map((c) => String(c.course_id))));
};
// "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones
const handleSelectSpecific = () => {
setBundleAll(false);
};
const total = courses.length;
const selectedCount = selectedIds.size;
if (!subscription) return null;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-muted-foreground">
{loading
? "Loading courses…"
: `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}`
}
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={loading || filtered.length === 0}
onClick={checkAll}
>
<Check className="size-3 mr-1" />
Check all
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs"
disabled={selectedCount === 0}
onClick={resetAll}
>
<RotateCcw className="size-3 mr-1" />
Reset
</Button>
</div>
</div>
<div className="space-y-4">
{/* ── Bundle question ──────────────────────────────────────────── */}
{loading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}
</div>
) : courses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses found with subscription <span className="font-mono font-medium ml-1">"{subscription}"</span>.
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
) : (
<Command className="rounded-lg border shadow-none" shouldFilter={false}>
<CommandInput
placeholder="Search courses…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No courses match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{course.title}</span>
{course.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{course.description}
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
</Command>
<div className="space-y-2.5">
<p className="text-sm">
Bundle <span className="font-semibold capitalize">{subscription}</span> courses with this plan?
</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={bundleAll ? "default" : "outline"}
onClick={handleBundleAll}
disabled={total === 0}
>
<Check className="size-3.5 mr-1.5" />
Yes, include all
</Button>
<Button
type="button"
size="sm"
variant={!bundleAll ? "default" : "outline"}
onClick={handleSelectSpecific}
disabled={total === 0}
>
No, choose specific
</Button>
</div>
</div>
)}
{/* ── Bundle all summary ───────────────────────────────────────── */}
{!loading && bundleAll && total > 0 && (
<p className="text-xs text-muted-foreground">
All {total} <span className="capitalize">{subscription}</span> course{total !== 1 ? "s" : ""} will be included.
</p>
)}
{/* ── 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">
<BookOpen className="size-4 shrink-0" />
No <span className="capitalize mx-1 font-medium">{subscription}</span> courses found. Add courses with this subscription first.
</div>
)}
{/* ── Specific picker (Popover) ─────────────────────────────────── */}
{!loading && !bundleAll && total > 0 && (
<div className="space-y-1.5">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"w-full justify-between gap-2",
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
)}
>
{selectedCount === 0
? "No courses selected"
: `${selectedCount} of ${total} course${total !== 1 ? "s" : ""} selected`
}
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search courses…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No courses match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{course.title}</span>
{course.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{course.description}
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
{/* Popover footer */}
<div className="border-t px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{selectedCount} of {total} selected
</span>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedCount === total ? true : selectedCount > 0 ? "indeterminate" : false}
onCheckedChange={(v) => v ? checkAll() : resetAll()}
/>
<span className="text-xs text-muted-foreground select-none">
{selectedCount === total ? "Deselect all" : "Select all"}
</span>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
{selectedCount === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Select at least one course to bundle with this plan.
</p>
)}
</div>
)}
</div>
);
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
import { useMemo, useRef, useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Layers } from "lucide-react";
@@ -8,7 +8,6 @@ import api from "@/utils/api.util";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
@@ -22,8 +21,7 @@ export default function TierPlansTable() {
const {
plans, planAttributes, planPagination, setPlanPagination,
loading, fetchPlans, deletePlan, restorePlan,
bulkDeletePlans, bulkRestorePlans,
loading, fetchPlans, deletePlan, bulkDeletePlans,
} = useTiers();
const [hasAvailableCategories, setHasAvailableCategories] = useState(true);
@@ -37,11 +35,8 @@ export default function TierPlansTable() {
.catch(() => {});
}, []);
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -52,30 +47,15 @@ export default function TierPlansTable() {
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
const handleToggleArchived = useCallback(() => {
const next = !showArchived;
setShowArchived(next);
fetchPlans({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: next,
});
}, [showArchived, planPagination, fetchPlans]);
const handleSuccess = () => {
setArchiveTarget(null);
setRestoreTarget(null);
setArchiveIds(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
fetchPlans({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: showArchived,
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
@@ -88,9 +68,7 @@ export default function TierPlansTable() {
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
onArchive: (row) => setArchiveTarget(row),
});
const toolbarActions = buildToolbarActions({
@@ -98,9 +76,7 @@ export default function TierPlansTable() {
pagination: planPagination,
exportConfig,
navigate,
showArchived,
hasAvailableCategories,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
@@ -108,10 +84,8 @@ export default function TierPlansTable() {
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
onRestoreMany: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
@@ -174,18 +148,6 @@ export default function TierPlansTable() {
onSuccess={handleSuccess}
/>
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onRestore={(entity) => restorePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk archive */}
<ArchiveDialog
open={!!archiveIds}
@@ -197,16 +159,6 @@ export default function TierPlansTable() {
onSuccess={handleSuccess}
/>
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Plan"
onRestore={({ ids }) => bulkRestorePlans(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
</>
);
}
@@ -0,0 +1,56 @@
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Book, BookOpenCheck, Clock } from "lucide-react";
import { formatDuration } from "@/utils/timestamp.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Book className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "unit" : "units"}
</Badge>
</div>
);
},
lessonCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "lesson" : "lessons"}
</Badge>
</div>
);
},
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{formatDuration(seconds)}
</Badge>
</div>
);
},
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
];
}
@@ -0,0 +1,20 @@
import { Eye, RotateCcw } from "lucide-react";
export function buildRowActions({ navigate, onRestore }) {
return [
{
key: "view",
label: "View Plan",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600",
onClick: (row) => onRestore(row),
separator: true,
},
];
}
@@ -0,0 +1,33 @@
import { Download, ArchiveRestore } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({
exportConfig,
onRestore,
onRestoreMany,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
},
},
];
}
@@ -0,0 +1,49 @@
import { RefreshCw, Download, ArchiveRestore } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchPlans,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchPlans({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
archived: true,
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
{
key: "active-plans",
type: "button",
label: "Active Plans",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/tiers/plans"),
},
];
}
@@ -14,6 +14,18 @@ export const columnPinning = {
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function fmtPlanDuration(days, unit) {
if (!days) return "—";
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" : ""}`;
}
// ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = {
unitCount: (info) => {
@@ -38,6 +50,16 @@ const cellOverrides = {
</div>
);
},
duration_days: (info) => {
const days = info.getValue();
const unit = info.row.original.duration_unit;
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm tabular-nums">{fmtPlanDuration(days, unit)}</span>
</div>
);
},
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -1,6 +1,6 @@
import { Eye, Pencil, Archive, RotateCcw, ShelvingUnit } from "lucide-react";
import { Eye, Pencil, Archive, ShelvingUnit, CreditCard, Globe } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
export function buildRowActions({ navigate, onArchive }) {
return [
{
key: "view",
@@ -13,15 +13,22 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
label: "Edit Plan",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
hidden: () => showArchived,
},
{
key: "view_units",
label: "View Payments",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
{
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}`),
separator: true
},
{
key: "archive",
@@ -30,15 +37,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true,
hidden: () => showArchived,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
},
];
}
@@ -1,12 +1,10 @@
import { Download, Archive, RotateCcw } from "lucide-react";
import { Download, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({
exportConfig,
showArchived,
onArchive,
onArchiveMany,
onRestoreMany,
getTableInstance,
}) {
return [
@@ -21,7 +19,7 @@ export function buildSelectionActions({
tableInstance: table ?? getTableInstance(),
}),
},
!showArchived && {
{
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
@@ -31,15 +29,5 @@ export function buildSelectionActions({
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
},
},
showArchived && {
key: "restore-selected",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-600",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
onRestoreMany(ids);
},
},
].filter(Boolean);
];
}
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react";
import { Plus, RefreshCw, Download, Archive, Layers, Globe } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
@@ -6,9 +6,7 @@ export function buildToolbarActions({
pagination,
exportConfig,
navigate,
showArchived,
hasAvailableCategories,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
@@ -21,11 +19,10 @@ export function buildToolbarActions({
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchPlans({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
archived: showArchived,
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
@@ -47,23 +44,31 @@ 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",
label: "New Plan",
icon: <Plus className="h-3.5 w-3.5" />,
variant: "default",
hidden: showArchived || !hasAvailableCategories,
hidden: !hasAvailableCategories,
onClick: () => navigate("/admin/tiers/plans/add"),
},
{
key: "toggle-archived",
key: "archived-plans",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: showArchived ? "Active Plans" : "Archived Plans",
label: "Archived Plans",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
onClick: () => navigate("/admin/tiers/plans/archived"),
},
];
}
+3 -2
View File
@@ -50,8 +50,9 @@ const AdminLayout = () => {
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center">
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
<img src="/philpro-white.png" alt="" className="object-cover" />
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
<img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" />
<img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" />
</div>
<div>
<svg
@@ -147,7 +147,7 @@ export default function AddAdvertisement() {
};
return (
<section className="bg-muted/60 h-full">
<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} />
@@ -51,7 +51,7 @@ export default function AdvertisementList() {
}
return (
<section className="bg-muted/60 h-full">
<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} />
@@ -189,7 +189,7 @@ export default function EditAdvertisement() {
};
return (
<section className="bg-muted/60 h-full">
<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} />
@@ -64,7 +64,7 @@ export default function ViewAdvertisement() {
if (loading && !advertisement) {
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
@@ -74,7 +74,7 @@ export default function ViewAdvertisement() {
if (!advertisement) {
return (
<section className="bg-muted/60 h-full">
<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={breadcrumbItems} />
@@ -93,7 +93,7 @@ export default function ViewAdvertisement() {
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return (
<section className="bg-muted/60 h-full">
<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} />
@@ -11,7 +11,7 @@ export default function ArchivedAssetList() {
];
return (
<section className="bg-muted/60 h-full">
<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">
<AppBreadcrumb items={items} />
+1 -1
View File
@@ -10,7 +10,7 @@ export default function AssetList() {
]
return (
<section className="bg-muted/60 h-full">
<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 ">
<AppBreadcrumb items={items} />
+252 -15
View File
@@ -1,20 +1,21 @@
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { ArrowLeft, Plus, Trash2, BadgeCheck, Trophy, Check, ChevronsUpDown, X, ImagePlus, Palette } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -22,6 +23,24 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 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";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -33,6 +52,7 @@ 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([]),
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -70,12 +90,18 @@ export default function AddCourse() {
.catch(() => {});
}, []);
// ─── Badge config state ─────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [achOpen, setAchOpen] = useState(false);
const {
register,
handleSubmit,
control,
setValue,
watch,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
@@ -87,18 +113,35 @@ export default function AddCourse() {
level: "beginner",
subscription: "free",
objectives: [],
achievement_keys: [],
},
});
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 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 });
}
};
const onSubmit = async (values) => {
const payload = {
...values,
objectives: values.objectives.map((o) => o.text),
level: values.level || null,
course_code: values.course_code || null,
badge_color: badgeColor,
badge_asset_id: badgeAssetId ?? null,
badge_image_url: badgeImageUrl ?? null,
createdBy: user?.user_id ?? null,
};
@@ -118,8 +161,8 @@ export default function AddCourse() {
<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>
<h1 className="text-xl font-semibold">Add Course</h1>
<p className="text-sm text-muted-foreground">Create a new training course.</p>
</div>
</div>
@@ -127,7 +170,6 @@ export default function AddCourse() {
{/* ── 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")} />
@@ -151,18 +193,15 @@ export default function AddCourse() {
<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={watch("level") ?? ""}
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
@@ -180,7 +219,7 @@ export default function AddCourse() {
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watch("subscription") ?? "free"}
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
@@ -196,9 +235,7 @@ export default function AddCourse() {
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
{/* ── Objectives ── */}
@@ -241,6 +278,195 @@ export default function AddCourse() {
</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>
</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>
)}
<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}/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}
>
<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" />
</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>
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
<Button
@@ -260,6 +486,17 @@ export default function AddCourse() {
</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>
);
}
}
@@ -11,7 +11,7 @@ export default function ArchivedCourseList() {
];
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title="Archived Courses - STARR" description="View archived training courses." />
<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">
@@ -10,7 +10,7 @@ export default function CourseList() {
];
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title="Courses - STARR" description="Browse and manage your training courses." />
<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">
+302 -47
View File
@@ -1,11 +1,15 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X } from "lucide-react";
import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X, Trophy, ImagePlus, Palette } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
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";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import { useCategories } from "@/contexts/AdminCategoriesContext";
@@ -39,6 +43,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -56,21 +61,6 @@ const schema = z.object({
// ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="prism-ec" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-ec)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
@@ -97,7 +87,7 @@ function SectionCard({ title, description, children }) {
export default function EditCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, loading, course } = useCourses();
const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, fetchCourseAchievements, syncCourseAchievements, loading, course } = useCourses();
const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth();
@@ -120,6 +110,20 @@ export default function EditCourse() {
const [instructorsDirty, setInstructorsDirty] = useState(false);
const [instructorsLoading, setInstructorsLoading] = useState(false);
// ─── Achievements state ───────────────────────────────────────────────────
const [selectedAchievementKeys, setSelectedAchievementKeys] = useState([]);
const [achievementsDirty, setAchievementsDirty] = useState(false);
const [achievementsLoading, setAchievementsLoading] = useState(false);
const [achOpen, setAchOpen] = useState(false);
// ─── Badge config state ───────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
const [badgeDirty, setBadgeDirty] = useState(false);
const [badgeLoading, setBadgeLoading] = useState(false);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
// ─── Product state ────────────────────────────────────────────────────────
const [product, setProduct] = useState(null);
const [productDirty, setProductDirty] = useState(false);
@@ -134,7 +138,6 @@ export default function EditCourse() {
reset,
control,
setValue,
watch,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
@@ -155,6 +158,12 @@ export default function EditCourse() {
remove: removeObjective,
} = useFieldArray({ control, name: "objectives" });
// useWatch is the correct hook for reading form values in render — avoids
// synchronous re-subscription loops that watch() can trigger in RHF 7.75+.
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscription = useWatch({ control, name: "subscription" });
// ─── Load existing course data ────────────────────────────────────────────
useEffect(() => {
(async () => {
@@ -174,15 +183,33 @@ export default function EditCourse() {
text: o.text ?? "",
})),
});
setBadgeColor(c.badge_color ?? "purple");
setBadgeAssetId(c.badge_asset_id ?? null);
// If a badge asset was saved, issue a fresh stream token so the preview
// works for private S3 images (stored file_url is a private CDN key).
if (c.badge_asset_id) {
api.post("/admin/media/token", { asset_id: c.badge_asset_id })
.then(({ data }) => {
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const thumbUrl = data.data?.thumbnail_url;
const token = data.data?.token;
setBadgeImageUrl(thumbUrl ?? (token ? `${STREAM_BASE}/${token}` : null));
})
.catch(() => setBadgeImageUrl(c.badge_image_url ?? null));
} else {
setBadgeImageUrl(c.badge_image_url ?? null);
}
})();
// Load categories, product, and instructors in parallel
// Load categories, product, instructors, and achievements in parallel
(async () => {
await fetchCategories();
const [cats, prod, insts] = await Promise.all([
const [cats, prod, insts, achKeys] = await Promise.all([
fetchCourseCategories(courseId),
fetchCourseProduct(courseId),
fetchInstructors(courseId),
fetchCourseAchievements(courseId),
]);
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
if (prod) {
@@ -203,6 +230,7 @@ export default function EditCourse() {
order_index: i.order_index ?? 0,
}))
);
setSelectedAchievementKeys(achKeys ?? []);
})();
}, [courseId]);
@@ -281,6 +309,36 @@ export default function EditCourse() {
setInstructorsLoading(false);
};
// ─── Badge handlers ───────────────────────────────────────────────────────
const handleSaveBadge = async () => {
setBadgeLoading(true);
await updateCourse(courseId, {
badge_color: badgeColor,
badge_asset_id: badgeAssetId,
badge_image_url: badgeImageUrl,
updatedBy: user?.user_id ?? null,
});
setBadgeDirty(false);
setBadgeLoading(false);
};
// ─── Achievement handlers ─────────────────────────────────────────────────
const toggleAchievement = (key) => {
setSelectedAchievementKeys((prev) => {
if (prev.includes(key)) return prev.filter((k) => k !== key);
if (prev.length >= 3) return prev;
return [...prev, key];
});
setAchievementsDirty(true);
};
const handleSaveAchievements = async () => {
setAchievementsLoading(true);
await syncCourseAchievements(courseId, selectedAchievementKeys);
setAchievementsDirty(false);
setAchievementsLoading(false);
};
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
@@ -381,7 +439,7 @@ export default function EditCourse() {
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watch("level") ?? ""}
value={watchedLevel ?? ""}
onValueChange={(val) =>
setValue("level", val, { shouldDirty: true })
}
@@ -401,7 +459,7 @@ export default function EditCourse() {
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watch("subscription") ?? "free"}
value={watchedSubscription ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
@@ -701,40 +759,237 @@ export default function EditCourse() {
</div>
</SectionCard>
{/* ── Certificate of Completion ── */}
{/* ── Rewards ── */}
<SectionCard
title="Certificate of Completion"
description="Automatically awarded to learners who pass this course's assessment. Mandatory for all courses."
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
<div className="flex items-start gap-4">
<CertBadgeIcon className="size-20 shrink-0" />
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Certificate of Completion</span>
<Badge className="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>
<p className="text-xs text-muted-foreground leading-relaxed">
This badge is issued automatically when a learner passes the course assessment.
It appears on their profile under <strong>Certificates</strong> and in the course
content listing for all enrolled users.
</p>
<div className="flex flex-col gap-0.5 mt-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Label:</span> Certificate of Completion
{/* ── 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>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
{/* 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); setBadgeDirty(true); }}
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="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
{/* 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>
)}
<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); setBadgeDirty(true); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
{/* Badge save */}
<div className="flex justify-end pt-3 mt-1 border-t">
<Button
type="button"
size="sm"
disabled={!badgeDirty || badgeLoading}
onClick={handleSaveBadge}
>
{badgeLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Badge
</Button>
</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">{selectedAchievementKeys.length}/3 selected</span>
</div>
{/* Selected badges */}
{selectedAchievementKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedAchievementKeys.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={selectedAchievementKeys.length >= 3}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{selectedAchievementKeys.length > 0
? `${selectedAchievementKeys.length} selected — add more`
: "Select achievements"}
</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">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = selectedAchievementKeys.includes(ach.key);
const disabled = !checked && selectedAchievementKeys.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 className="flex justify-end pt-3 mt-1 border-t">
<Button
type="button"
size="sm"
disabled={!achievementsDirty || achievementsLoading}
onClick={handleSaveAchievements}
>
{achievementsLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Achievements
</Button>
</div>
</div>
</SectionCard>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
// resolvedUrl is the already-authenticated stream/presigned URL
// from AssetPickerSheet — use it directly so private S3 images
// display in the badge preview instead of the inaccessible file_url.
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
setBadgeDirty(true);
}}
/>
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
<Button
+139 -9
View File
@@ -1,15 +1,18 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
ArrowLeft, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
Trophy, Users, Award,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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";
import { Skeleton } from "@/components/ui/skeleton";
@@ -71,10 +74,43 @@ export default function ViewCourse() {
const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat();
const [instructors, setInstructors] = useState([]);
const [achievementKeys, setAchievementKeys] = 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(() => {});
}, [courseId]);
// Fresh stream token for the badge image once course loads
useEffect(() => {
if (!course?.badge_asset_id) {
setBadgeImageUrl(course?.badge_image_url ?? null);
return;
}
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
api.post("/admin/media/token", { asset_id: course.badge_asset_id })
.then(({ data }) => {
const thumbUrl = data.data?.thumbnail_url;
const token = data.data?.token;
setBadgeImageUrl(thumbUrl ?? (token ? `${STREAM_BASE}/${token}` : null));
})
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
}, [course?.badge_asset_id, course?.badge_image_url]);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
@@ -155,9 +191,7 @@ export default function ViewCourse() {
<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`
: "—"}
{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">
@@ -188,6 +222,102 @@ export default function ViewCourse() {
</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">
@@ -207,11 +337,11 @@ export default function ViewCourse() {
<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">
{/* <InfoRow label="Required">
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
{course.assessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
</InfoRow> */}
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
@@ -247,4 +377,4 @@ export default function ViewCourse() {
</div>
</section>
);
}
}
@@ -57,7 +57,7 @@ export default function AddLesson() {
};
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -41,7 +41,7 @@ export default function ArchivedLessonsList() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={unit ? `Archived Lessons – ${unit.title} - STARR` : undefined} />
<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">
@@ -74,7 +74,7 @@ export default function EditLesson() {
};
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -19,7 +19,9 @@ export default function LessonsList() {
useEffect(() => {
(async () => {
await fetchCourse(courseId)
if (!course || String(course.course_id) !== String(courseId)) {
await fetchCourse(courseId);
}
await fetchUnit(courseId, unitId);
setInitializing(false);
})();
@@ -42,7 +44,7 @@ export default function LessonsList() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={unit ? `Lessons – ${unit.title} - STARR` : undefined} />
<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">
@@ -43,7 +43,7 @@ export default function ViewLesson() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
<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 space-y-6">
@@ -48,7 +48,7 @@ export default function AddUnit() {
};
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -39,7 +39,7 @@ export default function ArchivedUnitsList() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={course ? `Archived Units – ${course.title} - STARR` : undefined} />
<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">
@@ -60,7 +60,7 @@ export default function EditUnit() {
};
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -19,7 +19,9 @@ export default function UnitsList() {
useEffect(() => {
(async () => {
await fetchCourse(courseId);
if (!course || String(course.course_id) !== String(courseId)) {
await fetchCourse(courseId);
}
setInitializing(false);
})();
}, [courseId]);
@@ -40,7 +42,7 @@ export default function UnitsList() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={course ? `Units - ${course.title} - STARR` : undefined} />
<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">
@@ -31,7 +31,7 @@ export default function ViewUnit() {
}
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -39,7 +39,7 @@ export default function ViewUnit() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -10,7 +10,7 @@ export default function ArchiveTaskList() {
];
return (
<section className="bg-muted/60 h-full">
<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">
<AppBreadcrumb items={items} />
@@ -9,7 +9,7 @@ export default function TaskList() {
];
return (
<section className="bg-muted/60 h-full">
<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">
<AppBreadcrumb items={items} />
@@ -13,7 +13,7 @@ export default function ArchivedTask() {
];
return (
<section className="bg-muted/60 h-full">
<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">
<AppBreadcrumb items={items} />
+72 -17
View File
@@ -7,8 +7,9 @@ import { ArrowLeft, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
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 { 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 { PageMeta } from "@/contexts/MetadataContext";
@@ -17,12 +18,38 @@ import api from "@/utils/api.util";
// ─── Schema ───────────────────────────────────────────────────────────────────
const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" },
{ value: "hour", label: "Hour(s)" },
{ value: "day", label: "Day(s)" },
{ value: "month", label: "Month(s)" },
{ 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 },
month: { max: 11, nextLabel: "Year(s)", factor: 12 },
};
const schema = z.object({
tier_category_id: z.string().min(1, "Tier category is required."),
label: z.string().min(1, "Label is required."),
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
description: z.string().optional(),
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
duration_unit: z.string().min(1),
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
}).superRefine(({ duration_value, duration_unit }, ctx) => {
const rule = DURATION_UNIT_LIMITS[duration_unit];
if (rule && duration_value > rule.max) {
const equivalent = Math.floor(duration_value / rule.factor);
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["duration_value"],
message: `${duration_value} ${duration_unit}(s) = ${equivalent}+ ${rule.nextLabel.toLowerCase()}. Use ${rule.nextLabel} instead.`,
});
}
});
function FieldError({ message }) {
@@ -58,7 +85,7 @@ export default function AddPlan() {
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" },
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
const selectedCategoryId = watch("tier_category_id");
@@ -154,17 +181,48 @@ export default function AddPlan() {
<FieldError message={errors.label?.message} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
<FieldError message={errors.duration_days?.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 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>
<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">
@@ -176,9 +234,6 @@ export default function AddPlan() {
{categorySlug && (
<SectionCard title="Assigned Courses">
<p className="text-xs text-muted-foreground -mt-1">
Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
</p>
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedTierPlansTable from "../../components/tiers/ArchivedTierPlansTable";
import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext";
export default function ArchivedPlanList() {
const { fetchPlans } = useTiers();
useEffect(() => { fetchPlans({ archived: true }); }, []);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Archived" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Archived Plans - STARR" />
<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">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchivedTierPlansTable />
</div>
</div>
</section>
);
}
+210 -44
View File
@@ -3,25 +3,61 @@ import { useNavigate, useParams } 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, House, TriangleAlert } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
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 { 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 { Skeleton } from "@/components/ui/skeleton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 api from "@/utils/api.util";
const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" },
{ value: "hour", label: "Hour(s)" },
{ value: "day", label: "Day(s)" },
{ value: "month", label: "Month(s)" },
{ 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) {
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
return Math.round((days / multiplier) * 1000) / 1000;
}
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
month: { max: 11, nextLabel: "Year(s)", factor: 12 },
};
const schema = z.object({
label: z.string().min(1, "Label is required."),
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3),
is_active: z.boolean().default(true),
label: z.string().min(1, "Label is required."),
description: z.string().optional(),
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
duration_unit: z.string().min(1),
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3),
is_active: z.boolean().default(true),
}).superRefine(({ duration_value, duration_unit }, ctx) => {
const rule = DURATION_UNIT_LIMITS[duration_unit];
if (rule && duration_value > rule.max) {
const equivalent = Math.floor(duration_value / rule.factor);
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["duration_value"],
message: `${duration_value} ${duration_unit}(s) = ${equivalent}+ ${rule.nextLabel.toLowerCase()}. Use ${rule.nextLabel} instead.`,
});
}
});
function FieldError({ message }) {
@@ -44,8 +80,12 @@ export default function EditPlan() {
const { planId } = useParams();
const { fetchPlan, plan, updatePlan, loading } = useTiers();
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
const [impactLoading, setImpactLoading] = useState(false);
const [pendingValues, setPendingValues] = useState(null);
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema),
@@ -57,12 +97,15 @@ export default function EditPlan() {
useEffect(() => {
if (plan) {
const unit = plan.duration_unit ?? "day";
reset({
label: plan.label,
duration_days: plan.duration_days,
price: plan.price,
currency: plan.currency,
is_active: plan.is_active,
label: plan.label,
description: plan.description ?? "",
duration_value: durationDaysToValue(plan.duration_days, unit),
duration_unit: unit,
price: plan.price,
currency: plan.currency,
is_active: plan.is_active,
});
}
}, [plan]);
@@ -70,7 +113,7 @@ export default function EditPlan() {
// Load existing assigned courses once the plan is known
useEffect(() => {
if (!planId || coursesLoaded) return;
api.get(`/admin/tiers/plans/${planId}/courses`)
api.get(`/admin/tiers/${planId}/courses`)
.then(({ data }) => {
const ids = (data.data ?? []).map((c) => String(c.course_id));
setSelectedCourseIds(new Set(ids));
@@ -79,18 +122,51 @@ export default function EditPlan() {
.catch(() => setCoursesLoaded(true));
}, [planId]);
const onSubmit = async (values) => {
const durationChanged = (values) => {
if (!plan) return false;
const originalUnit = plan.duration_unit ?? "day";
const originalValue = durationDaysToValue(plan.duration_days, originalUnit);
return (
Number(values.duration_value) !== originalValue ||
values.duration_unit !== originalUnit
);
};
const doSave = async (values) => {
const result = await updatePlan(planId, values);
if (!result) return;
// Always sync (empty array clears all assignments)
await api.post(`/admin/tiers/plans/${planId}/courses`, {
course_ids: [...selectedCourseIds].map(Number),
await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds],
}).catch(() => {});
navigate("/admin/tiers/plans");
};
const onSubmit = async (values) => {
if (!durationChanged(values)) {
return doSave(values);
}
setImpactLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${planId}/impact`);
setImpactCount(data.data?.active_subscriber_count ?? 0);
setPendingValues(values);
setImpactDialog(true);
} catch {
// Impact fetch failed — still allow save but without count
setImpactCount(0);
setPendingValues(values);
setImpactDialog(true);
} finally {
setImpactLoading(false);
}
};
const handleConfirmSave = async () => {
setImpactDialog(false);
if (pendingValues) await doSave(pendingValues);
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} />
@@ -129,17 +205,48 @@ export default function EditPlan() {
<FieldError message={errors.label?.message} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="duration_days">Duration (days)</Label>
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
<FieldError message={errors.duration_days?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price</Label>
<Input id="price" type="number" step="0.01" {...register("price")} />
<FieldError message={errors.price?.message} />
<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</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</Label>
<Input id="price" type="number" step="0.01" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
@@ -151,7 +258,7 @@ export default function EditPlan() {
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Inactive plans won't appear to users.</p>
<p className="text-xs text-muted-foreground">Inactive plans are visible to users but cannot be purchased.</p>
</div>
<Switch
checked={watch("is_active") ?? true}
@@ -162,21 +269,29 @@ export default function EditPlan() {
{plan?.tier && (
<SectionCard title="Assigned Courses">
<p className="text-xs text-muted-foreground -mt-1">
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan.
</p>
<CoursePicker
subscription={plan.tier}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
/>
{coursesLoaded ? (
<CoursePicker
subscription={plan.tier}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
isPreloaded={true}
/>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</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}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
@@ -184,6 +299,57 @@ export default function EditPlan() {
)}
</div>
</div>
{/* ── Duration Impact Warning Dialog ──────────────────────────────────── */}
<Dialog open={impactDialog} onOpenChange={(v) => { if (!v) { setImpactDialog(false); setPendingValues(null); } }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<TriangleAlert className="h-5 w-5 shrink-0" />
Duration Change Warning
</DialogTitle>
<DialogDescription>
You are about to change the subscription duration for this plan.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 space-y-2 text-sm">
{impactCount > 0 ? (
<>
<p className="font-semibold text-destructive">
{impactCount} active subscriber{impactCount !== 1 ? "s" : ""} will be affected.
</p>
<p className="text-muted-foreground">
Their current expiry dates are based on the <span className="font-medium text-foreground">previous duration</span>.
This change will only apply to <span className="font-medium text-foreground">new subscriptions</span> going forward — existing active subscribers will not have their expiry recalculated automatically.
</p>
</>
) : (
<p className="text-muted-foreground">
No active subscribers are currently on this plan. The new duration will apply to all future subscriptions.
</p>
)}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => { setImpactDialog(false); setPendingValues(null); }}
disabled={loading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmSave}
disabled={loading}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Confirm & Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -4,29 +4,76 @@ import {
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
Diamond, Swords, Mountain, Sun, Globe, Compass, Flag, GraduationCap,
Key, Fingerprint, Eye, Crosshair, Infinity, TrendingUp, Triangle, Octagon,
Circle, Disc, Feather, Snowflake, Leaf, TreePine, Gift, Heart,
HeartHandshake, Bird, BadgePlus, BadgePercent, Sigma, BookOpen,
Milestone, Navigation, Sunrise,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
{ name: "BadgeCheck", icon: BadgeCheck },
{ name: "Star", icon: Star },
{ name: "Crown", icon: Crown },
{ name: "Gem", icon: Gem },
{ name: "Trophy", icon: Trophy },
{ name: "Medal", icon: Medal },
{ name: "Award", icon: Award },
{ name: "Sparkles", icon: Sparkles },
{ name: "Flame", icon: Flame },
{ name: "Zap", icon: Zap },
{ name: "Rocket", icon: Rocket },
{ name: "Target", icon: Target },
{ name: "Hexagon", icon: Hexagon },
{ name: "Layers", icon: Layers },
{ name: "CircleDot", icon: CircleDot },
// Prestige / rank
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
{ name: "BadgeCheck", icon: BadgeCheck },
{ name: "BadgePlus", icon: BadgePlus },
{ name: "BadgePercent", icon: BadgePercent },
{ name: "Crown", icon: Crown },
{ name: "Star", icon: Star },
{ name: "Sparkles", icon: Sparkles },
{ name: "Diamond", icon: Diamond },
{ name: "Gem", icon: Gem },
// Awards
{ name: "Trophy", icon: Trophy },
{ name: "Medal", icon: Medal },
{ name: "Award", icon: Award },
{ name: "Gift", icon: Gift },
{ name: "Milestone", icon: Milestone },
// Power / energy
{ name: "Flame", icon: Flame },
{ name: "Zap", icon: Zap },
{ name: "Rocket", icon: Rocket },
{ name: "Sun", icon: Sun },
{ name: "Sunrise", icon: Sunrise },
// Action / skill
{ name: "Swords", icon: Swords },
{ name: "Target", icon: Target },
{ name: "Crosshair", icon: Crosshair },
{ name: "TrendingUp", icon: TrendingUp },
{ name: "Sigma", icon: Sigma },
// Navigation / scope
{ name: "Compass", icon: Compass },
{ name: "Navigation", icon: Navigation },
{ name: "Globe", icon: Globe },
{ name: "Mountain", icon: Mountain },
{ name: "Flag", icon: Flag },
// Knowledge
{ name: "GraduationCap", icon: GraduationCap },
{ name: "BookOpen", icon: BookOpen },
{ name: "Key", icon: Key },
{ name: "Fingerprint", icon: Fingerprint },
{ name: "Eye", icon: Eye },
// Nature
{ name: "Leaf", icon: Leaf },
{ name: "TreePine", icon: TreePine },
{ name: "Snowflake", icon: Snowflake },
{ name: "Bird", icon: Bird },
{ name: "Feather", icon: Feather },
// Heart
{ name: "Heart", icon: Heart },
{ name: "HeartHandshake",icon: HeartHandshake },
{ name: "Infinity", icon: Infinity },
// Shapes
{ name: "Hexagon", icon: Hexagon },
{ name: "Octagon", icon: Octagon },
{ name: "Triangle", icon: Triangle },
{ name: "Circle", icon: Circle },
{ name: "Disc", icon: Disc },
{ name: "CircleDot", icon: CircleDot },
{ name: "Layers", icon: Layers },
];
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -0,0 +1,525 @@
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>
);
}
@@ -24,7 +24,7 @@ export default function PaymentList() {
];
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title="Payments - STARR" />
<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">
@@ -0,0 +1,492 @@
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 { DateTimePicker } from "@/components/ui/date-time-picker";
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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
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_PROMO = {
code: "", type: "flat", value: "", currency: "USD",
max_discount: "", max_uses: "", expires_at: "", min_amount: "",
};
const WINDOW_UNITS = ["minutes", "hours", "days"];
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function PaymentPolicy() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading } = useTiers();
const [policy, setPolicy] = useState(null);
const [policyLoading, setPolicyLoading] = useState(true);
const [saving, setSaving] = useState(false);
// ── Refund policy fields
const [refundAllowed, setRefundAllowed] = useState(true);
const [refundWindowValue, setRefundWindowValue] = useState(5);
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
// ── Promo rules
const [promoRules, setPromoRules] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
// ── Localized prices (for notice in promo section)
const [localizedPrices, setLocalizedPrices] = useState([]);
// ─── Load ────────────────────────────────────────────────────────────────────
useEffect(() => {
fetchPlan(planId);
setPolicyLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
.then(({ data }) => {
const p = data.data;
if (p) {
setPolicy(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]);
// ─── Save ────────────────────────────────────────────────────────────────────
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.");
navigate("/admin/tiers/plans");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
} finally {
setSaving(false);
}
};
// ─── Promo helpers ────────────────────────────────────────────────────────────
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);
};
const handleRemovePromo = (code) =>
setPromoRules((prev) => prev.filter((r) => r.code !== code));
// ─── Render ───────────────────────────────────────────────────────────────────
const isLoading = planLoading || policyLoading;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Payment Policy — ${plan.label} - STARR` : "Payment Policy - 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: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Payment Policy" },
]} />
</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">Payment Policy</h1>
<p className="text-sm text-muted-foreground capitalize">
{plan?.tier} — {plan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
) : (
<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}
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>
{/* ── Promo Codes ───────────────────────────────────────────── */}
<SectionCard
icon={Tag}
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">
{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={() => handleRemovePromo(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>
)}
{/* Add form */}
{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>
{/* ── Save ─────────────────────────────────────────────────── */}
<div className="flex justify-end gap-3 pt-1">
<Button variant="outline" onClick={() => navigate(-1)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Policy
</Button>
</div>
</div>
)}
</div>
</div>
</section>
);
}
+53 -2
View File
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { House } from "lucide-react";
import { House, CreditCard, Tag, ShieldCheck, Globe } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import TierPlansTable from "../../components/tiers/TierPlansTable";
import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Badge } from "@/components/ui/badge";
export default function PlanList() {
const { fetchPlans } = useTiers();
@@ -16,13 +17,63 @@ export default function PlanList() {
];
return (
<section className="bg-muted/60 h-full">
<section className="bg-muted h-full">
<PageMeta title="Plans - STARR" />
<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">
<AppBreadcrumb items={items} />
</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>
</div>
@@ -143,7 +143,7 @@ function TierCategoriesInner() {
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Tier Category</DialogTitle>
<DialogDescription>
+83 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck } from "lucide-react";
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";
@@ -47,6 +47,24 @@ function LoadingSkeleton() {
);
}
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' : ''}`;
}
function formatCourseDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
@@ -56,9 +74,17 @@ export default function ViewPlan() {
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);
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
setCoursesLoading(true);
api.get(`/admin/tiers/${planId}/courses`)
.then(({ data }) => setAssignedCourses(data.data ?? []))
.catch(() => {})
.finally(() => setCoursesLoading(false));
}, [planId]);
return (
@@ -111,7 +137,7 @@ export default function ViewPlan() {
<InfoRow label="Tier">
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow>
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow>
@@ -122,6 +148,61 @@ export default function ViewPlan() {
</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">
@@ -11,7 +11,7 @@ export default function ArchivedGroupList() {
]
return (
<section className="bg-muted/60 h-full">
<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 ">
<AppBreadcrumb items={items} />
@@ -10,7 +10,7 @@ export default function GroupList() {
]
return (
<section className="bg-muted/60 h-full">
<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 ">
<AppBreadcrumb items={items} />
@@ -11,7 +11,7 @@ export default function ArchivedUserList() {
]
return (
<section className="bg-muted/60 h-full">
<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 ">
<AppBreadcrumb items={items} />
+1 -1
View File
@@ -10,7 +10,7 @@ export default function UserList() {
]
return (
<section className="bg-muted/60 h-full">
<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 ">
<AppBreadcrumb items={items} />
+9 -2
View File
@@ -90,6 +90,9 @@ import UserTierList from '../pages/tiers/UserTierList';
import PaymentList from '../pages/tiers/PaymentList';
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'
@@ -252,10 +255,14 @@ export const AdminRoutes = {
children: [
{ index: true, element: <PlanList /> },
{ path: 'add', element: <AddPlan /> },
{ path: ':planId/view', element: <ViewPlan /> },
{ path: ':planId/edit', element: <EditPlan /> },
{ path: 'archived', element: <ArchivedPlanList /> },
{ 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',